promptgraph-mcp 1.1.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 +2 -1
- package/ann.js +50 -0
- package/db.js +44 -40
- package/github-import.js +5 -4
- package/indexer.js +16 -14
- package/package.json +3 -2
- package/parser.js +36 -47
- package/search.js +42 -24
package/README.md
CHANGED
|
@@ -128,7 +128,8 @@ promptgraph-mcp reindex # Re-index all skills
|
|
|
128
128
|
|---|---|---|
|
|
129
129
|
| Skills in context | All 40+ | 1 (router) |
|
|
130
130
|
| Tokens per session | ~20,000–50,000 | ~150 + 1 skill |
|
|
131
|
-
|
|
131
|
+
|
|
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.
|
|
132
133
|
|
|
133
134
|
## File Structure
|
|
134
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/db.js
CHANGED
|
@@ -1,40 +1,44 @@
|
|
|
1
|
-
import Database from 'better-sqlite3';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
|
-
import fs from 'fs';
|
|
5
|
-
|
|
6
|
-
const DB_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
|
|
7
|
-
|
|
8
|
-
export function getDb() {
|
|
9
|
-
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
10
|
-
const db = new Database(DB_PATH);
|
|
11
|
-
db.pragma('journal_mode = WAL');
|
|
12
|
-
|
|
13
|
-
db.exec(`
|
|
14
|
-
CREATE TABLE IF NOT EXISTS skills (
|
|
15
|
-
id
|
|
16
|
-
name TEXT
|
|
17
|
-
description TEXT,
|
|
18
|
-
path TEXT NOT NULL,
|
|
19
|
-
source TEXT NOT NULL,
|
|
20
|
-
content TEXT NOT NULL
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
CREATE TABLE IF NOT EXISTS chunks (
|
|
24
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
-
|
|
26
|
-
chunk_index INTEGER NOT NULL,
|
|
27
|
-
text TEXT NOT NULL,
|
|
28
|
-
embedding TEXT NOT NULL,
|
|
29
|
-
UNIQUE(
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
CREATE TABLE IF NOT EXISTS edges (
|
|
33
|
-
from_skill TEXT NOT NULL,
|
|
34
|
-
to_skill TEXT NOT NULL,
|
|
35
|
-
PRIMARY KEY (from_skill, to_skill)
|
|
36
|
-
);
|
|
37
|
-
`);
|
|
38
|
-
|
|
39
|
-
return db;
|
|
40
|
-
}
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const DB_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
|
|
7
|
+
|
|
8
|
+
export function getDb() {
|
|
9
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
10
|
+
const db = new Database(DB_PATH);
|
|
11
|
+
db.pragma('journal_mode = WAL');
|
|
12
|
+
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
name TEXT NOT NULL,
|
|
17
|
+
description TEXT,
|
|
18
|
+
path TEXT NOT NULL,
|
|
19
|
+
source TEXT NOT NULL,
|
|
20
|
+
content TEXT NOT NULL
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS chunks (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
skill_id TEXT NOT NULL,
|
|
26
|
+
chunk_index INTEGER NOT NULL,
|
|
27
|
+
text TEXT NOT NULL,
|
|
28
|
+
embedding TEXT NOT NULL,
|
|
29
|
+
UNIQUE(skill_id, chunk_index)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
33
|
+
from_skill TEXT NOT NULL,
|
|
34
|
+
to_skill TEXT NOT NULL,
|
|
35
|
+
PRIMARY KEY (from_skill, to_skill)
|
|
36
|
+
);
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
return db;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function skillId(source, name) {
|
|
43
|
+
return `${source}::${name}`;
|
|
44
|
+
}
|
package/github-import.js
CHANGED
|
@@ -2,6 +2,7 @@ import { execSync } from 'child_process';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import fs from 'fs';
|
|
5
|
+
import { globSync } from 'glob';
|
|
5
6
|
import { indexAll } from './indexer.js';
|
|
6
7
|
import { loadConfig, saveConfig } from './config.js';
|
|
7
8
|
|
|
@@ -27,10 +28,10 @@ export async function importFromGitHub(repoUrl) {
|
|
|
27
28
|
execSync(`git clone --depth=1 ${url} "${dest}"`, { stdio: 'inherit' });
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
-
console.log(`Found ${
|
|
31
|
+
const mdFiles = globSync(`${dest}/**/*.md`);
|
|
32
|
+
console.log(`Found ${mdFiles.length} .md files`);
|
|
32
33
|
|
|
33
|
-
if (
|
|
34
|
+
if (mdFiles.length < 2) {
|
|
34
35
|
console.warn('Warning: repo has fewer than 2 .md files — may be empty');
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -44,5 +45,5 @@ export async function importFromGitHub(repoUrl) {
|
|
|
44
45
|
|
|
45
46
|
console.log('\nReindexing...');
|
|
46
47
|
await indexAll();
|
|
47
|
-
console.log(`Done! Imported from ${repoName}`);
|
|
48
|
+
console.log(`Done! Imported ${mdFiles.length} files from ${repoName}`);
|
|
48
49
|
}
|
package/indexer.js
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
1
|
import { globSync } from 'glob';
|
|
2
2
|
import { parseSkillFile } from './parser.js';
|
|
3
3
|
import { embed } from './embedder.js';
|
|
4
|
-
import { getDb } from './db.js';
|
|
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) {
|
|
10
|
+
const id = skillId(skill.source, skill.name);
|
|
11
|
+
|
|
9
12
|
db.prepare(`
|
|
10
|
-
INSERT INTO skills (name, description, path, source, content)
|
|
11
|
-
VALUES (@name, @description, @path, @source, @content)
|
|
12
|
-
ON CONFLICT(
|
|
13
|
+
INSERT INTO skills (id, name, description, path, source, content)
|
|
14
|
+
VALUES (@id, @name, @description, @path, @source, @content)
|
|
15
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
16
|
+
name = excluded.name,
|
|
13
17
|
description = excluded.description,
|
|
14
18
|
path = excluded.path,
|
|
15
|
-
source = excluded.source,
|
|
16
19
|
content = excluded.content
|
|
17
|
-
`).run({ name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content });
|
|
20
|
+
`).run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content });
|
|
18
21
|
|
|
19
|
-
db.prepare('DELETE FROM chunks WHERE
|
|
22
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(id);
|
|
20
23
|
|
|
21
24
|
const chunks = chunkText(skill.name + ' ' + skill.description + '\n' + skill.content);
|
|
22
|
-
const upsertChunk = db.prepare(`
|
|
23
|
-
INSERT OR REPLACE INTO chunks (skill_name, chunk_index, text, embedding)
|
|
24
|
-
VALUES (?, ?, ?, ?)
|
|
25
|
-
`);
|
|
25
|
+
const upsertChunk = db.prepare(`INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)`);
|
|
26
26
|
for (let i = 0; i < chunks.length; i++) {
|
|
27
27
|
const vec = await embed(chunks[i]);
|
|
28
|
-
upsertChunk.run(
|
|
28
|
+
upsertChunk.run(id, i, chunks[i], JSON.stringify(vec));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
db.prepare('DELETE FROM edges WHERE from_skill = ?').run(
|
|
31
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ?').run(id);
|
|
32
32
|
const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
|
|
33
33
|
for (const called of skill.calls) {
|
|
34
|
-
upsertEdge.run(
|
|
34
|
+
upsertEdge.run(id, called);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -54,6 +54,8 @@ export async function indexAll() {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
process.stdout.write('\r Building ANN index...');
|
|
58
|
+
await buildAnnIndex();
|
|
57
59
|
console.log(`\n Done. ${count} skills indexed.`);
|
|
58
60
|
}
|
|
59
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/parser.js
CHANGED
|
@@ -1,47 +1,36 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
export function parseSkillFile(filePath, source) {
|
|
8
|
-
const raw = fs.readFileSync(filePath, 'utf8');
|
|
9
|
-
|
|
10
|
-
let name
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
source,
|
|
38
|
-
content: raw,
|
|
39
|
-
calls: [...calls],
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function extractFirstParagraph(content) {
|
|
44
|
-
const lines = content.replace(FRONTMATTER_RE, '').split('\n')
|
|
45
|
-
.filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
|
|
46
|
-
return lines[0]?.trim().slice(0, 200) || '';
|
|
47
|
-
}
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
4
|
+
|
|
5
|
+
const SKILL_REF_RE = /\/([a-z0-9][a-z0-9-]+)/g;
|
|
6
|
+
|
|
7
|
+
export function parseSkillFile(filePath, source) {
|
|
8
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
9
|
+
|
|
10
|
+
let name, description, content;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const { data, content: body } = matter(raw);
|
|
14
|
+
name = data.name;
|
|
15
|
+
description = data.description;
|
|
16
|
+
content = body;
|
|
17
|
+
} catch {
|
|
18
|
+
content = raw;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
name = (name && String(name).trim()) || path.basename(filePath, '.md');
|
|
22
|
+
description = (description && String(description).trim()) || extractFirstParagraph(content || raw);
|
|
23
|
+
|
|
24
|
+
const calls = new Set();
|
|
25
|
+
for (const match of raw.matchAll(SKILL_REF_RE)) {
|
|
26
|
+
const ref = match[1];
|
|
27
|
+
if (ref !== name && ref.length > 2) calls.add(ref);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { name, description, path: filePath, source, content: raw, calls: [...calls] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function extractFirstParagraph(content) {
|
|
34
|
+
const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
|
|
35
|
+
return lines[0]?.trim().slice(0, 200) || '';
|
|
36
|
+
}
|
package/search.js
CHANGED
|
@@ -1,54 +1,72 @@
|
|
|
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
|
-
const
|
|
9
|
+
// Try ANN index first (fast, O(log N))
|
|
10
|
+
const annResults = await annSearch(queryVec, topK * 4);
|
|
10
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
|
+
}
|
|
27
|
+
|
|
28
|
+
// Fallback: brute force (used before first reindex)
|
|
29
|
+
const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
|
|
11
30
|
const bestBySkill = new Map();
|
|
12
31
|
for (const chunk of chunks) {
|
|
13
32
|
const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
|
|
14
|
-
const prev = bestBySkill.get(chunk.
|
|
15
|
-
if (!prev || score > prev) bestBySkill.set(chunk.
|
|
33
|
+
const prev = bestBySkill.get(chunk.skill_id);
|
|
34
|
+
if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
|
|
16
35
|
}
|
|
17
|
-
|
|
18
|
-
const skillNames = [...bestBySkill.entries()]
|
|
36
|
+
return [...bestBySkill.entries()]
|
|
19
37
|
.sort((a, b) => b[1] - a[1])
|
|
20
38
|
.slice(0, topK)
|
|
21
|
-
.map(([
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
});
|
|
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);
|
|
27
44
|
}
|
|
28
45
|
|
|
29
|
-
export function getContext(
|
|
46
|
+
export function getContext(id) {
|
|
30
47
|
const db = getDb();
|
|
31
|
-
const skill = db.prepare('SELECT * FROM skills WHERE
|
|
48
|
+
const skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(id)
|
|
49
|
+
|| db.prepare('SELECT * FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(id);
|
|
32
50
|
if (!skill) return null;
|
|
33
|
-
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(
|
|
34
|
-
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(
|
|
51
|
+
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(skill.id).map(r => r.to_skill);
|
|
52
|
+
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(skill.id).map(r => r.from_skill);
|
|
35
53
|
return { ...skill, callees, callers };
|
|
36
54
|
}
|
|
37
55
|
|
|
38
|
-
export function getCallers(
|
|
56
|
+
export function getCallers(id) {
|
|
39
57
|
const db = getDb();
|
|
40
|
-
return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(
|
|
58
|
+
return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(id).map(r => r.from_skill);
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
export function getCallees(
|
|
61
|
+
export function getCallees(id) {
|
|
44
62
|
const db = getDb();
|
|
45
|
-
return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(
|
|
63
|
+
return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(id).map(r => r.to_skill);
|
|
46
64
|
}
|
|
47
65
|
|
|
48
|
-
export function getImpact(
|
|
66
|
+
export function getImpact(id) {
|
|
49
67
|
const db = getDb();
|
|
50
68
|
const visited = new Set();
|
|
51
|
-
const queue = [
|
|
69
|
+
const queue = [id];
|
|
52
70
|
while (queue.length) {
|
|
53
71
|
const cur = queue.shift();
|
|
54
72
|
if (visited.has(cur)) continue;
|
|
@@ -56,11 +74,11 @@ export function getImpact(name) {
|
|
|
56
74
|
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(cur).map(r => r.from_skill);
|
|
57
75
|
queue.push(...callers);
|
|
58
76
|
}
|
|
59
|
-
visited.delete(
|
|
77
|
+
visited.delete(id);
|
|
60
78
|
return [...visited];
|
|
61
79
|
}
|
|
62
80
|
|
|
63
81
|
export function listAll() {
|
|
64
82
|
const db = getDb();
|
|
65
|
-
return db.prepare('SELECT name, description, source FROM skills ORDER BY name').all();
|
|
83
|
+
return db.prepare('SELECT id, name, description, source FROM skills ORDER BY source, name').all();
|
|
66
84
|
}
|