promptgraph-mcp 2.4.8 → 2.6.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/api.js +4 -2
- package/bundle-counts.js +3 -2
- package/config.js +7 -0
- package/db.js +125 -83
- package/github-import.js +69 -7
- package/index.js +20 -598
- package/indexer.js +5 -9
- package/marketplace.js +83 -2
- package/package.json +2 -2
- package/search.js +22 -20
- package/src/reranker/reranker.js +28 -0
- package/src/utils/rate-limiter.js +33 -0
- package/validator.js +24 -0
- package/src/filter/cluster.js +0 -52
- package/src/filter/dedup.js +0 -36
package/api.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function index(sourceDir, sourceName) {
|
|
|
39
39
|
const path = await import('path');
|
|
40
40
|
const { createHash } = await import('crypto');
|
|
41
41
|
const { parseSkillFile, isSkillFile } = await import('./parser.js');
|
|
42
|
-
const { embedBatch
|
|
42
|
+
const { embedBatch } = await import('./embedder.js');
|
|
43
|
+
const { BATCH_SIZE } = await import('./config.js');
|
|
43
44
|
const { skillId, vecToBlob } = await import('./db.js');
|
|
44
45
|
const { chunkText } = await import('./chunker.js');
|
|
45
46
|
|
|
@@ -105,7 +106,8 @@ export async function update() {
|
|
|
105
106
|
const path = await import('path');
|
|
106
107
|
const { createHash } = await import('crypto');
|
|
107
108
|
const { parseSkillFile, isSkillFile } = await import('./parser.js');
|
|
108
|
-
const { embedBatch
|
|
109
|
+
const { embedBatch } = await import('./embedder.js');
|
|
110
|
+
const { BATCH_SIZE } = await import('./config.js');
|
|
109
111
|
const { skillId, vecToBlob } = await import('./db.js');
|
|
110
112
|
const { chunkText } = await import('./chunker.js');
|
|
111
113
|
const { indexBatch } = await import('./indexer.js');
|
package/bundle-counts.js
CHANGED
|
@@ -20,12 +20,13 @@ function httpsGet(url) {
|
|
|
20
20
|
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
21
21
|
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
22
22
|
return new Promise((res, rej) => {
|
|
23
|
-
const req = https.get(url, { headers }, r => {
|
|
23
|
+
const req = https.get(url, { headers, timeout: 15000 }, r => {
|
|
24
24
|
if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
|
|
25
25
|
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
26
|
-
|
|
26
|
+
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
27
27
|
});
|
|
28
28
|
req.on('error', rej);
|
|
29
|
+
req.on('timeout', () => { req.destroy(new Error('timeout')); });
|
|
29
30
|
});
|
|
30
31
|
}
|
|
31
32
|
|
package/config.js
CHANGED
|
@@ -8,6 +8,13 @@ export const PROMPTGRAPH_DIR = path.join(CLAUDE_DIR, '.promptgraph');
|
|
|
8
8
|
export const SKILLS_STORE_DIR = path.join(CLAUDE_DIR, 'skills-store');
|
|
9
9
|
const CONFIG_PATH = path.join(PROMPTGRAPH_DIR, 'config.json');
|
|
10
10
|
|
|
11
|
+
export const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024 // 50 MB per file
|
|
12
|
+
export const MAX_FILE_COUNT = 50000 // max files per repo
|
|
13
|
+
export const MAX_REPO_SIZE = 500 * 1024 * 1024 // 500 MB per repo
|
|
14
|
+
export const RATE_LIMIT_REQUESTS = 30 // requests per window
|
|
15
|
+
export const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute window
|
|
16
|
+
export const BATCH_SIZE = 100 // batch indexing size
|
|
17
|
+
|
|
11
18
|
const DEFAULTS = {
|
|
12
19
|
sources: [
|
|
13
20
|
{ dir: path.join(CLAUDE_DIR, 'skills-store'), source: 'skills-store' },
|
package/db.js
CHANGED
|
@@ -7,96 +7,138 @@ const DB_PATH = path.join(PROMPTGRAPH_DIR, 'promptgraph.db');
|
|
|
7
7
|
|
|
8
8
|
let _db = null;
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
10
|
+
const MIGRATIONS = [
|
|
11
|
+
{
|
|
12
|
+
version: 1, description: 'initial schema',
|
|
13
|
+
up: db => 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
|
+
hash TEXT
|
|
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 BLOB NOT NULL,
|
|
29
|
+
UNIQUE(skill_id, chunk_index)
|
|
30
|
+
);
|
|
31
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
32
|
+
from_skill TEXT NOT NULL,
|
|
33
|
+
to_skill TEXT NOT NULL,
|
|
34
|
+
PRIMARY KEY (from_skill, to_skill)
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS ratings (
|
|
37
|
+
skill_id TEXT PRIMARY KEY,
|
|
38
|
+
uses INTEGER DEFAULT 0,
|
|
39
|
+
success INTEGER DEFAULT 0,
|
|
40
|
+
fail INTEGER DEFAULT 0
|
|
41
|
+
);
|
|
42
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
|
|
43
|
+
id UNINDEXED, name, description, content,
|
|
44
|
+
content='skills', content_rowid='rowid'
|
|
45
|
+
);
|
|
46
|
+
`),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
version: 2, description: 'add hash column',
|
|
50
|
+
up: db => {
|
|
51
|
+
const cols = db.pragma('table_info(skills)').map(c => c.name);
|
|
52
|
+
if (!cols.includes('hash')) db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
version: 3, description: 'add registry metadata columns',
|
|
57
|
+
up: db => {
|
|
58
|
+
const cols = db.pragma('table_info(skills)').map(c => c.name);
|
|
59
|
+
for (const [col, def] of [
|
|
60
|
+
['version', 'TEXT'],
|
|
61
|
+
['author', 'TEXT'],
|
|
62
|
+
['license', 'TEXT'],
|
|
63
|
+
['updated_at', 'TEXT'],
|
|
64
|
+
['downloads', 'INTEGER DEFAULT 0'],
|
|
65
|
+
['verified', 'INTEGER DEFAULT 0'],
|
|
66
|
+
['trust_level', "TEXT DEFAULT 'unknown'"],
|
|
67
|
+
['rating', 'REAL DEFAULT 0'],
|
|
68
|
+
['rating_count', 'INTEGER DEFAULT 0'],
|
|
69
|
+
['popularity', 'REAL DEFAULT 0'],
|
|
70
|
+
['last_update', 'TEXT'],
|
|
71
|
+
]) {
|
|
72
|
+
if (!cols.includes(col)) db.exec(`ALTER TABLE skills ADD COLUMN ${col} ${def}`);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
version: 4, description: 'registry_entries table',
|
|
78
|
+
up: db => db.exec(`
|
|
79
|
+
CREATE TABLE IF NOT EXISTS registry_entries (
|
|
80
|
+
id TEXT PRIMARY KEY,
|
|
81
|
+
trust_level TEXT DEFAULT 'unknown',
|
|
82
|
+
downloads INTEGER DEFAULT 0,
|
|
83
|
+
rating REAL DEFAULT 0,
|
|
84
|
+
rating_count INTEGER DEFAULT 0,
|
|
85
|
+
popularity REAL DEFAULT 0,
|
|
86
|
+
last_update TEXT
|
|
87
|
+
);
|
|
88
|
+
`),
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
version: 5, description: 'convert TEXT embeddings to Float32 BLOB',
|
|
92
|
+
up: db => {
|
|
93
|
+
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
|
94
|
+
if (textEmbeddings?.n > 0) {
|
|
95
|
+
const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
|
|
96
|
+
const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
|
|
97
|
+
db.transaction(() => {
|
|
98
|
+
for (const row of rows) {
|
|
99
|
+
const vec = JSON.parse(row.embedding);
|
|
100
|
+
upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
|
|
101
|
+
}
|
|
102
|
+
})();
|
|
103
|
+
console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
]
|
|
59
108
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
109
|
+
function getSchemaVersion(db) {
|
|
110
|
+
try {
|
|
111
|
+
return db.prepare('SELECT MAX(version) as v FROM _schema_version').get().v || 0
|
|
112
|
+
} catch {
|
|
113
|
+
return 0
|
|
64
114
|
}
|
|
115
|
+
}
|
|
65
116
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
if (!cols.includes('license')) {
|
|
74
|
-
db.exec('ALTER TABLE skills ADD COLUMN license TEXT');
|
|
75
|
-
}
|
|
76
|
-
if (!cols.includes('updated_at')) {
|
|
77
|
-
db.exec('ALTER TABLE skills ADD COLUMN updated_at TEXT');
|
|
78
|
-
}
|
|
79
|
-
if (!cols.includes('downloads')) {
|
|
80
|
-
db.exec('ALTER TABLE skills ADD COLUMN downloads INTEGER DEFAULT 0');
|
|
81
|
-
}
|
|
82
|
-
if (!cols.includes('verified')) {
|
|
83
|
-
db.exec('ALTER TABLE skills ADD COLUMN verified INTEGER DEFAULT 0');
|
|
84
|
-
}
|
|
117
|
+
function runMigrations(db) {
|
|
118
|
+
const current = getSchemaVersion(db)
|
|
119
|
+
const pending = MIGRATIONS.filter(m => m.version > current).sort((a, b) => a.version - b.version)
|
|
120
|
+
if (!pending.length) return
|
|
121
|
+
|
|
122
|
+
db.exec(`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT)`)
|
|
85
123
|
|
|
86
|
-
|
|
87
|
-
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
|
88
|
-
if (textEmbeddings?.n > 0) {
|
|
89
|
-
const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
|
|
90
|
-
const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
|
|
124
|
+
for (const migration of pending) {
|
|
91
125
|
db.transaction(() => {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
})()
|
|
97
|
-
console.error(`[PromptGraph]
|
|
126
|
+
migration.up(db)
|
|
127
|
+
db.prepare('INSERT INTO _schema_version (version, description, applied_at) VALUES (?, ?, ?)').run(
|
|
128
|
+
migration.version, migration.description, new Date().toISOString()
|
|
129
|
+
)
|
|
130
|
+
})()
|
|
131
|
+
console.error(`[PromptGraph] DB migrated to v${migration.version}: ${migration.description}`)
|
|
98
132
|
}
|
|
133
|
+
}
|
|
99
134
|
|
|
135
|
+
export function getDb() {
|
|
136
|
+
if (_db) return _db;
|
|
137
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
138
|
+
const db = new Database(DB_PATH);
|
|
139
|
+
_db = db;
|
|
140
|
+
db.pragma('journal_mode = WAL');
|
|
141
|
+
runMigrations(db);
|
|
100
142
|
return db;
|
|
101
143
|
}
|
|
102
144
|
|
package/github-import.js
CHANGED
|
@@ -4,24 +4,71 @@ import fs from 'fs';
|
|
|
4
4
|
import https from 'https';
|
|
5
5
|
import { globSync } from 'glob';
|
|
6
6
|
import { indexAll, indexSource } from './indexer.js';
|
|
7
|
-
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
|
|
7
|
+
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR, MAX_DOWNLOAD_SIZE, MAX_FILE_COUNT, MAX_REPO_SIZE, RATE_LIMIT_REQUESTS, RATE_LIMIT_WINDOW_MS } from './config.js';
|
|
8
8
|
import { validateSkill } from './validator.js';
|
|
9
9
|
import { isSkillFile } from './parser.js';
|
|
10
|
+
import { RateLimiter } from './src/utils/rate-limiter.js';
|
|
11
|
+
|
|
12
|
+
const githubRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
13
|
+
const downloadRateLimiter = new RateLimiter({ maxRequests: RATE_LIMIT_REQUESTS * 2, windowMs: RATE_LIMIT_WINDOW_MS })
|
|
10
14
|
|
|
11
15
|
const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
|
|
12
16
|
|
|
13
17
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
const repoStats = new Map()
|
|
20
|
+
|
|
21
|
+
function getRepoStats(ownerRepo) {
|
|
22
|
+
if (!repoStats.has(ownerRepo)) {
|
|
23
|
+
repoStats.set(ownerRepo, { totalBytes: 0, fileCount: 0 })
|
|
24
|
+
}
|
|
25
|
+
return repoStats.get(ownerRepo)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function streamDownload(url, maxSize = MAX_DOWNLOAD_SIZE, redirects = 0) {
|
|
29
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
30
|
+
return new Promise((res, rej) => {
|
|
31
|
+
const token = process.env.GITHUB_TOKEN;
|
|
32
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
33
|
+
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
34
|
+
const req = https.get(url, { headers }, r => {
|
|
35
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
36
|
+
return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
|
|
37
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
38
|
+
const cl = parseInt(r.headers['content-length'], 10);
|
|
39
|
+
if (!isNaN(cl) && cl > maxSize) {
|
|
40
|
+
r.resume();
|
|
41
|
+
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
42
|
+
}
|
|
43
|
+
const chunks = []
|
|
44
|
+
let total = 0
|
|
45
|
+
r.setEncoding('utf8')
|
|
46
|
+
r.on('data', c => {
|
|
47
|
+
total += Buffer.byteLength(c, 'utf8')
|
|
48
|
+
if (total > maxSize) {
|
|
49
|
+
r.destroy()
|
|
50
|
+
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
51
|
+
}
|
|
52
|
+
chunks.push(c)
|
|
53
|
+
})
|
|
54
|
+
r.on('end', () => res(chunks.join('')))
|
|
55
|
+
})
|
|
56
|
+
req.on('error', rej)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function httpsGet(url, redirects = 0) {
|
|
61
|
+
if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
|
|
62
|
+
await githubRateLimiter.acquire()
|
|
16
63
|
const token = process.env.GITHUB_TOKEN;
|
|
17
64
|
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
18
65
|
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
19
66
|
return new Promise((res, rej) => {
|
|
20
67
|
const req = https.get(url, { headers }, r => {
|
|
21
68
|
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
22
|
-
return httpsGet(r.headers.location).then(res, rej);
|
|
69
|
+
return httpsGet(r.headers.location, redirects + 1).then(res, rej);
|
|
23
70
|
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
24
|
-
|
|
71
|
+
const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
|
|
25
72
|
});
|
|
26
73
|
req.on('error', rej);
|
|
27
74
|
});
|
|
@@ -39,11 +86,22 @@ function repoExists(ownerRepo) {
|
|
|
39
86
|
}
|
|
40
87
|
|
|
41
88
|
// Download one .md file, run validateSkill on it, return errors/warnings.
|
|
42
|
-
async function validateMdFile(file, tmpDir) {
|
|
89
|
+
async function validateMdFile(file, tmpDir, ownerRepo) {
|
|
43
90
|
const errors = [];
|
|
44
91
|
const warnings = [];
|
|
92
|
+
const stats = getRepoStats(ownerRepo);
|
|
45
93
|
try {
|
|
46
|
-
|
|
94
|
+
if (stats.fileCount >= MAX_FILE_COUNT) {
|
|
95
|
+
errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
|
|
96
|
+
return { errors, warnings };
|
|
97
|
+
}
|
|
98
|
+
await downloadRateLimiter.acquire()
|
|
99
|
+
const content = await streamDownload(file.download_url);
|
|
100
|
+
stats.totalBytes += Buffer.byteLength(content, 'utf8');
|
|
101
|
+
stats.fileCount++;
|
|
102
|
+
if (stats.totalBytes > MAX_REPO_SIZE) {
|
|
103
|
+
return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
|
|
104
|
+
}
|
|
47
105
|
const tmpPath = path.join(tmpDir, file.name);
|
|
48
106
|
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
49
107
|
fs.writeFileSync(tmpPath, content);
|
|
@@ -119,7 +177,7 @@ export async function validateRepoSkills(ownerRepo) {
|
|
|
119
177
|
let warnings = [];
|
|
120
178
|
|
|
121
179
|
for (const file of mdToValidate) {
|
|
122
|
-
const r = await validateMdFile(file, tmpDir);
|
|
180
|
+
const r = await validateMdFile(file, tmpDir, ownerRepo);
|
|
123
181
|
errors.push(...r.errors);
|
|
124
182
|
warnings.push(...r.warnings);
|
|
125
183
|
}
|
|
@@ -464,6 +522,10 @@ export async function importFromGitHub(repoUrl) {
|
|
|
464
522
|
const label = skillsSubdir || localLabel;
|
|
465
523
|
const mdFiles = globSync(`${skillsDir}/**/*.md`);
|
|
466
524
|
|
|
525
|
+
if (mdFiles.length > MAX_FILE_COUNT) {
|
|
526
|
+
console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
|
|
527
|
+
}
|
|
528
|
+
|
|
467
529
|
if (skillsSubdir) {
|
|
468
530
|
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
469
531
|
} else {
|