promptgraph-mcp 2.4.8 → 2.5.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/config.js +7 -0
- package/db.js +28 -0
- package/github-import.js +65 -5
- package/indexer.js +4 -1
- package/marketplace.js +83 -2
- package/package.json +2 -2
- package/search.js +22 -20
- package/src/reranker/reranker.js +27 -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/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
|
@@ -82,6 +82,34 @@ export function getDb() {
|
|
|
82
82
|
if (!cols.includes('verified')) {
|
|
83
83
|
db.exec('ALTER TABLE skills ADD COLUMN verified INTEGER DEFAULT 0');
|
|
84
84
|
}
|
|
85
|
+
if (!cols.includes('trust_level')) {
|
|
86
|
+
db.exec('ALTER TABLE skills ADD COLUMN trust_level TEXT DEFAULT \'unknown\'');
|
|
87
|
+
}
|
|
88
|
+
if (!cols.includes('rating')) {
|
|
89
|
+
db.exec('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
|
|
90
|
+
}
|
|
91
|
+
if (!cols.includes('rating_count')) {
|
|
92
|
+
db.exec('ALTER TABLE skills ADD COLUMN rating_count INTEGER DEFAULT 0');
|
|
93
|
+
}
|
|
94
|
+
if (!cols.includes('popularity')) {
|
|
95
|
+
db.exec('ALTER TABLE skills ADD COLUMN popularity REAL DEFAULT 0');
|
|
96
|
+
}
|
|
97
|
+
if (!cols.includes('last_update')) {
|
|
98
|
+
db.exec('ALTER TABLE skills ADD COLUMN last_update TEXT');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// migrate: registry entries metadata table
|
|
102
|
+
db.exec(`
|
|
103
|
+
CREATE TABLE IF NOT EXISTS registry_entries (
|
|
104
|
+
id TEXT PRIMARY KEY,
|
|
105
|
+
trust_level TEXT DEFAULT 'unknown',
|
|
106
|
+
downloads INTEGER DEFAULT 0,
|
|
107
|
+
rating REAL DEFAULT 0,
|
|
108
|
+
rating_count INTEGER DEFAULT 0,
|
|
109
|
+
popularity REAL DEFAULT 0,
|
|
110
|
+
last_update TEXT
|
|
111
|
+
);
|
|
112
|
+
`);
|
|
85
113
|
|
|
86
114
|
// migrate: convert JSON text embeddings to Float32 BLOB (one-time, ~10x smaller)
|
|
87
115
|
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
package/github-import.js
CHANGED
|
@@ -4,15 +4,60 @@ 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) {
|
|
29
|
+
return new Promise((res, rej) => {
|
|
30
|
+
const token = process.env.GITHUB_TOKEN;
|
|
31
|
+
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
32
|
+
if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
|
|
33
|
+
const req = https.get(url, { headers }, r => {
|
|
34
|
+
if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
|
|
35
|
+
return streamDownload(r.headers.location, maxSize).then(res, rej);
|
|
36
|
+
if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
|
|
37
|
+
const cl = parseInt(r.headers['content-length'], 10);
|
|
38
|
+
if (!isNaN(cl) && cl > maxSize) {
|
|
39
|
+
r.resume();
|
|
40
|
+
return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
|
|
41
|
+
}
|
|
42
|
+
let d = ''
|
|
43
|
+
let total = 0
|
|
44
|
+
r.setEncoding('utf8')
|
|
45
|
+
r.on('data', c => {
|
|
46
|
+
total += Buffer.byteLength(c, 'utf8')
|
|
47
|
+
if (total > maxSize) {
|
|
48
|
+
r.destroy()
|
|
49
|
+
return rej(new Error(`Download exceeded ${maxSize} bytes`))
|
|
50
|
+
}
|
|
51
|
+
d += c
|
|
52
|
+
})
|
|
53
|
+
r.on('end', () => res(d))
|
|
54
|
+
})
|
|
55
|
+
req.on('error', rej)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function httpsGet(url) {
|
|
60
|
+
await githubRateLimiter.acquire()
|
|
16
61
|
const token = process.env.GITHUB_TOKEN;
|
|
17
62
|
const headers = { 'User-Agent': 'promptgraph-mcp' };
|
|
18
63
|
if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
|
|
@@ -39,11 +84,22 @@ function repoExists(ownerRepo) {
|
|
|
39
84
|
}
|
|
40
85
|
|
|
41
86
|
// Download one .md file, run validateSkill on it, return errors/warnings.
|
|
42
|
-
async function validateMdFile(file, tmpDir) {
|
|
87
|
+
async function validateMdFile(file, tmpDir, ownerRepo) {
|
|
43
88
|
const errors = [];
|
|
44
89
|
const warnings = [];
|
|
90
|
+
const stats = getRepoStats(ownerRepo);
|
|
45
91
|
try {
|
|
46
|
-
|
|
92
|
+
if (stats.fileCount >= MAX_FILE_COUNT) {
|
|
93
|
+
errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
|
|
94
|
+
return { errors, warnings };
|
|
95
|
+
}
|
|
96
|
+
await downloadRateLimiter.acquire()
|
|
97
|
+
const content = await streamDownload(file.download_url);
|
|
98
|
+
stats.totalBytes += Buffer.byteLength(content, 'utf8');
|
|
99
|
+
stats.fileCount++;
|
|
100
|
+
if (stats.totalBytes > MAX_REPO_SIZE) {
|
|
101
|
+
return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
|
|
102
|
+
}
|
|
47
103
|
const tmpPath = path.join(tmpDir, file.name);
|
|
48
104
|
fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
|
|
49
105
|
fs.writeFileSync(tmpPath, content);
|
|
@@ -119,7 +175,7 @@ export async function validateRepoSkills(ownerRepo) {
|
|
|
119
175
|
let warnings = [];
|
|
120
176
|
|
|
121
177
|
for (const file of mdToValidate) {
|
|
122
|
-
const r = await validateMdFile(file, tmpDir);
|
|
178
|
+
const r = await validateMdFile(file, tmpDir, ownerRepo);
|
|
123
179
|
errors.push(...r.errors);
|
|
124
180
|
warnings.push(...r.warnings);
|
|
125
181
|
}
|
|
@@ -464,6 +520,10 @@ export async function importFromGitHub(repoUrl) {
|
|
|
464
520
|
const label = skillsSubdir || localLabel;
|
|
465
521
|
const mdFiles = globSync(`${skillsDir}/**/*.md`);
|
|
466
522
|
|
|
523
|
+
if (mdFiles.length > MAX_FILE_COUNT) {
|
|
524
|
+
console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
|
|
525
|
+
}
|
|
526
|
+
|
|
467
527
|
if (skillsSubdir) {
|
|
468
528
|
console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
|
|
469
529
|
} else {
|
package/indexer.js
CHANGED
|
@@ -3,7 +3,8 @@ import { createHash } from 'crypto';
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { parseSkillFile, isSkillFile, filterWithClassifier } from './parser.js';
|
|
6
|
-
import { embedBatch, cosineSimilarity
|
|
6
|
+
import { embedBatch, cosineSimilarity } from './embedder.js';
|
|
7
|
+
import { BATCH_SIZE } from './config.js';
|
|
7
8
|
import { getDb, skillId, vecToBlob } from './db.js';
|
|
8
9
|
import { loadConfig } from './config.js';
|
|
9
10
|
import { chunkText } from './chunker.js';
|
|
@@ -210,6 +211,7 @@ export async function indexAll({ fast = false } = {}) {
|
|
|
210
211
|
batch = [];
|
|
211
212
|
const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
|
|
212
213
|
progress(count, total, { skipped, eta, errors });
|
|
214
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
213
215
|
}
|
|
214
216
|
} catch (e) {
|
|
215
217
|
errors++;
|
|
@@ -301,6 +303,7 @@ export async function indexSource(dir, sourceName) {
|
|
|
301
303
|
await indexBatch(db, batch);
|
|
302
304
|
count += batch.length; batch = [];
|
|
303
305
|
progress(count, total, { skipped, errors });
|
|
306
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
304
307
|
}
|
|
305
308
|
} catch (e) { errors++; count++; }
|
|
306
309
|
}
|
package/marketplace.js
CHANGED
|
@@ -61,6 +61,16 @@ function validateRegistryEntry(item) {
|
|
|
61
61
|
}
|
|
62
62
|
if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
|
|
63
63
|
if (item.verified !== undefined && typeof item.verified !== 'boolean') errors.push('verified must be a boolean');
|
|
64
|
+
if (item.trustLevel !== undefined && typeof item.trustLevel !== 'string') errors.push('trustLevel must be a string');
|
|
65
|
+
if (item.rating !== undefined) {
|
|
66
|
+
if (typeof item.rating !== 'number') errors.push('rating must be a number');
|
|
67
|
+
else if (item.rating < 0 || item.rating > 5) errors.push('rating must be between 0 and 5');
|
|
68
|
+
}
|
|
69
|
+
if (item.popularity !== undefined && typeof item.popularity !== 'number') errors.push('popularity must be a number');
|
|
70
|
+
if (item.lastUpdate !== undefined) {
|
|
71
|
+
if (typeof item.lastUpdate !== 'string') errors.push('lastUpdate must be a string');
|
|
72
|
+
else if (isNaN(Date.parse(item.lastUpdate))) errors.push(`lastUpdate "${item.lastUpdate}" is not a valid ISO date`);
|
|
73
|
+
}
|
|
64
74
|
if (errors.length > 0) {
|
|
65
75
|
console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
|
|
66
76
|
return { ok: false };
|
|
@@ -162,7 +172,7 @@ export async function browseMarketplace(topK = 20) {
|
|
|
162
172
|
const registry = JSON.parse(text);
|
|
163
173
|
if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
|
|
164
174
|
return registry.skills
|
|
165
|
-
.map(s => (
|
|
175
|
+
.map(s => Object.assign(Object.create(null), s, { code: s.code || codeFor(s.id) }))
|
|
166
176
|
.filter(s => validateRegistryEntry(s).ok)
|
|
167
177
|
.filter(s => s.raw_url)
|
|
168
178
|
.sort((a, b) => (b.stars || 0) - (a.stars || 0))
|
|
@@ -287,7 +297,7 @@ export async function browseBundles(topK = 20) {
|
|
|
287
297
|
|
|
288
298
|
return bundles
|
|
289
299
|
.filter(b => !b.repo_url || b.skillCount > 0)
|
|
290
|
-
.map(b => (
|
|
300
|
+
.map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
|
|
291
301
|
.sort((a, b) => (b.stars || 0) - (a.stars || 0))
|
|
292
302
|
.slice(0, topK);
|
|
293
303
|
} catch (e) {
|
|
@@ -548,3 +558,74 @@ export function pruneInvalidRepos() {
|
|
|
548
558
|
saveConfig(config);
|
|
549
559
|
return { removed, kept, errors };
|
|
550
560
|
}
|
|
561
|
+
|
|
562
|
+
// ── Trust level system ─────────────────────────────────────────────────────────
|
|
563
|
+
const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
|
|
564
|
+
|
|
565
|
+
function calcPopularity(downloads = 0, rating = 0) {
|
|
566
|
+
return Math.round((downloads || 0) * ((rating || 0) + 1) * 100) / 100
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export async function setTrustLevel(name, level) {
|
|
570
|
+
const db = getDb()
|
|
571
|
+
if (!VALID_TRUST_LEVELS.includes(level)) {
|
|
572
|
+
return { ok: false, error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
|
|
573
|
+
}
|
|
574
|
+
const id = String(name)
|
|
575
|
+
db.prepare(`
|
|
576
|
+
INSERT INTO registry_entries (id, trust_level, last_update)
|
|
577
|
+
VALUES (?, ?, datetime('now'))
|
|
578
|
+
ON CONFLICT(id) DO UPDATE SET trust_level = excluded.trust_level, last_update = excluded.last_update
|
|
579
|
+
`).run(id, level)
|
|
580
|
+
return { ok: true }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export async function getByTrustLevel(level) {
|
|
584
|
+
const db = getDb()
|
|
585
|
+
if (level && !VALID_TRUST_LEVELS.includes(level)) {
|
|
586
|
+
return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
|
|
587
|
+
}
|
|
588
|
+
if (level) {
|
|
589
|
+
return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY id').all(level)
|
|
590
|
+
}
|
|
591
|
+
return db.prepare('SELECT * FROM registry_entries ORDER BY id').all()
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
export async function incrementDownloads(name) {
|
|
595
|
+
const db = getDb()
|
|
596
|
+
const id = String(name)
|
|
597
|
+
const existing = db.prepare('SELECT downloads, rating FROM registry_entries WHERE id = ?').get(id)
|
|
598
|
+
if (existing) {
|
|
599
|
+
const newDownloads = (existing.downloads || 0) + 1
|
|
600
|
+
const pop = calcPopularity(newDownloads, existing.rating || 0)
|
|
601
|
+
db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
|
|
602
|
+
.run(newDownloads, pop, id)
|
|
603
|
+
} else {
|
|
604
|
+
const pop = calcPopularity(1, 0)
|
|
605
|
+
db.prepare('INSERT INTO registry_entries (id, downloads, popularity, last_update) VALUES (?, 1, ?, datetime(\'now\'))')
|
|
606
|
+
.run(id, pop)
|
|
607
|
+
}
|
|
608
|
+
return { ok: true }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function rateSkill(name, rating) {
|
|
612
|
+
const db = getDb()
|
|
613
|
+
if (typeof rating !== 'number' || rating < 0 || rating > 5) {
|
|
614
|
+
return { ok: false, error: 'Rating must be a number between 0 and 5' }
|
|
615
|
+
}
|
|
616
|
+
const id = String(name)
|
|
617
|
+
const existing = db.prepare('SELECT rating, rating_count, downloads FROM registry_entries WHERE id = ?').get(id)
|
|
618
|
+
if (existing) {
|
|
619
|
+
const count = existing.rating_count || 0
|
|
620
|
+
const newRating = Math.round(((existing.rating || 0) * count + rating) / (count + 1) * 100) / 100
|
|
621
|
+
const newCount = count + 1
|
|
622
|
+
const pop = calcPopularity(existing.downloads || 0, newRating)
|
|
623
|
+
db.prepare('UPDATE registry_entries SET rating = ?, rating_count = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
|
|
624
|
+
.run(newRating, newCount, pop, id)
|
|
625
|
+
} else {
|
|
626
|
+
const pop = calcPopularity(0, rating)
|
|
627
|
+
db.prepare('INSERT INTO registry_entries (id, rating, rating_count, popularity, last_update) VALUES (?, ?, 1, ?, datetime(\'now\'))')
|
|
628
|
+
.run(id, rating, pop)
|
|
629
|
+
}
|
|
630
|
+
return { ok: true }
|
|
631
|
+
}
|
package/package.json
CHANGED
package/search.js
CHANGED
|
@@ -2,24 +2,10 @@ import { embed, cosineSimilarity } from './embedder.js';
|
|
|
2
2
|
import { getDb, blobToVec } from './db.js';
|
|
3
3
|
import { annSearch } from './ann.js';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
let bm25Weight = 0.3;
|
|
7
|
-
|
|
8
|
-
export function setHybridWeights(ew, bw) {
|
|
9
|
-
embedWeight = ew;
|
|
10
|
-
bm25Weight = bw;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function adaptWeights(query) {
|
|
14
|
-
// Technical terms (caps, digits) → BM25 matters more
|
|
5
|
+
function getHybridWeights(query) {
|
|
15
6
|
const hasTechTerms = /[A-Z]|\d/.test(query);
|
|
16
|
-
if (hasTechTerms) {
|
|
17
|
-
|
|
18
|
-
bm25Weight = 0.5;
|
|
19
|
-
} else {
|
|
20
|
-
embedWeight = 0.7;
|
|
21
|
-
bm25Weight = 0.3;
|
|
22
|
-
}
|
|
7
|
+
if (hasTechTerms) return { embedWeight: 0.5, bm25Weight: 0.5 }
|
|
8
|
+
return { embedWeight: 0.7, bm25Weight: 0.3 }
|
|
23
9
|
}
|
|
24
10
|
|
|
25
11
|
function applyRatingBoost(db, id, score) {
|
|
@@ -82,7 +68,7 @@ function runBM25Search(db, query, topK) {
|
|
|
82
68
|
|
|
83
69
|
export async function search(query, topK = 5) {
|
|
84
70
|
const db = getDb();
|
|
85
|
-
|
|
71
|
+
const { embedWeight, bm25Weight } = getHybridWeights(query);
|
|
86
72
|
const queryVec = await embed(query);
|
|
87
73
|
|
|
88
74
|
const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
|
|
@@ -116,13 +102,29 @@ export async function search(query, topK = 5) {
|
|
|
116
102
|
}
|
|
117
103
|
}
|
|
118
104
|
|
|
119
|
-
|
|
105
|
+
const ordered = [...combined.entries()]
|
|
120
106
|
.map(([id, s]) => ({
|
|
121
107
|
id,
|
|
122
108
|
score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
|
|
123
109
|
}))
|
|
124
110
|
.sort((a, b) => b.score - a.score)
|
|
125
|
-
|
|
111
|
+
|
|
112
|
+
const rerankerEnabled = process.env.PG_RERANKER !== '0'
|
|
113
|
+
|
|
114
|
+
if (rerankerEnabled) {
|
|
115
|
+
const { Reranker } = await import('./src/reranker/reranker.js')
|
|
116
|
+
const reranker = new Reranker()
|
|
117
|
+
const topN = ordered.slice(0, 20)
|
|
118
|
+
.map(({ id, score }) => {
|
|
119
|
+
const s = skillWithSnippet(db, id, score)
|
|
120
|
+
return s ? { id, text: s.snippet, score } : null
|
|
121
|
+
})
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
const reranked = await reranker.rerank(query, topN)
|
|
124
|
+
return reranked.map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score))).filter(Boolean)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return ordered.slice(0, topK)
|
|
126
128
|
.map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
|
|
127
129
|
.filter(Boolean);
|
|
128
130
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export class Reranker {
|
|
2
|
+
constructor(modelName = 'default', device = 'cpu') {
|
|
3
|
+
this.modelName = modelName
|
|
4
|
+
this.device = device
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// Cross-encoder style reranker
|
|
8
|
+
// Plug-in point for BGE Reranker / MiniLM Reranker via ONNX
|
|
9
|
+
async rerank(query, results) {
|
|
10
|
+
if (!results || results.length === 0) return []
|
|
11
|
+
|
|
12
|
+
const queryTerms = (query.toLowerCase().match(/\w+/g) || [])
|
|
13
|
+
const queryTermCount = queryTerms.length || 1
|
|
14
|
+
|
|
15
|
+
const scored = results.map(r => {
|
|
16
|
+
const text = (r.text || '').toLowerCase()
|
|
17
|
+
const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
...r,
|
|
21
|
+
score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, 5)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export class RateLimiter {
|
|
2
|
+
constructor({ maxRequests, windowMs }) {
|
|
3
|
+
this.maxRequests = maxRequests
|
|
4
|
+
this.windowMs = windowMs
|
|
5
|
+
this.timestamps = []
|
|
6
|
+
this._chain = Promise.resolve()
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
async acquire() {
|
|
10
|
+
const prev = this._chain
|
|
11
|
+
this._chain = prev.then(() => this._doAcquire())
|
|
12
|
+
return this._chain
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async _doAcquire() {
|
|
16
|
+
while (!this.tryAcquire()) {
|
|
17
|
+
const oldest = this.timestamps[0]
|
|
18
|
+
const waitMs = oldest
|
|
19
|
+
? Math.max(1, oldest + this.windowMs - Date.now())
|
|
20
|
+
: 100
|
|
21
|
+
await new Promise(r => setTimeout(r, Math.min(waitMs, this.windowMs)))
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
tryAcquire() {
|
|
26
|
+
const now = Date.now()
|
|
27
|
+
const cutoff = now - this.windowMs
|
|
28
|
+
this.timestamps = this.timestamps.filter(t => t > cutoff)
|
|
29
|
+
if (this.timestamps.length >= this.maxRequests) return false
|
|
30
|
+
this.timestamps.push(now)
|
|
31
|
+
return true
|
|
32
|
+
}
|
|
33
|
+
}
|
package/validator.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import matter from 'gray-matter';
|
|
4
|
+
import { MAX_DOWNLOAD_SIZE } from './config.js';
|
|
3
5
|
|
|
4
6
|
// patterns that indicate malicious or junk skills
|
|
5
7
|
const DANGEROUS_PATTERNS = [
|
|
@@ -86,6 +88,19 @@ export function validateSkill(filePath) {
|
|
|
86
88
|
if (pathParts.includes('..')) {
|
|
87
89
|
errors.push('Path traversal detected: file path contains ".."');
|
|
88
90
|
}
|
|
91
|
+
|
|
92
|
+
// extension whitelist — reject unexpected file types
|
|
93
|
+
const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.yaml', '.yml', '.txt', '.js', '.ts', '.py', '.rs', '.toml']);
|
|
94
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
95
|
+
if (!ALLOWED_EXTENSIONS.has(ext)) {
|
|
96
|
+
errors.push(`File extension "${ext}" not allowed. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// binary content detection — must be valid UTF-8 with no null bytes
|
|
100
|
+
if (raw.includes('\0')) {
|
|
101
|
+
errors.push('File contains null bytes (binary content)');
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
if (['readme.md', 'changelog.md', 'license.md', 'contributing.md'].includes(base)) {
|
|
90
105
|
warnings.push('Filename looks like a docs file, not a skill.');
|
|
91
106
|
}
|
|
@@ -142,6 +157,15 @@ export function validateBundle(def) {
|
|
|
142
157
|
return { ok: errors.length === 0, errors, warnings };
|
|
143
158
|
}
|
|
144
159
|
|
|
160
|
+
export function sanitizeExternalContent(content) {
|
|
161
|
+
if (typeof content !== 'string') return ''
|
|
162
|
+
let sanitized = content.replace(/\0/g, '')
|
|
163
|
+
if (Buffer.byteLength(sanitized, 'utf8') > MAX_DOWNLOAD_SIZE) {
|
|
164
|
+
sanitized = Buffer.from(sanitized, 'utf8').subarray(0, MAX_DOWNLOAD_SIZE).toString('utf8')
|
|
165
|
+
}
|
|
166
|
+
return sanitized
|
|
167
|
+
}
|
|
168
|
+
|
|
145
169
|
// CLI: node validator.js <file>
|
|
146
170
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
147
171
|
const file = process.argv[2];
|
package/src/filter/cluster.js
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { cosineSimilarity } from '../../embedder.js';
|
|
2
|
-
|
|
3
|
-
const INFERENCE_THRESHOLD = 0.35;
|
|
4
|
-
|
|
5
|
-
export function findClusters(skills, embeddings) {
|
|
6
|
-
if (!skills.length || !embeddings) return [];
|
|
7
|
-
const clusters = [];
|
|
8
|
-
const assigned = new Set();
|
|
9
|
-
|
|
10
|
-
for (let i = 0; i < skills.length; i++) {
|
|
11
|
-
if (assigned.has(i)) continue;
|
|
12
|
-
const cluster = { centroid: embeddings[i], members: [skills[i]], indices: [i] };
|
|
13
|
-
assigned.add(i);
|
|
14
|
-
|
|
15
|
-
for (let j = i + 1; j < skills.length; j++) {
|
|
16
|
-
if (assigned.has(j)) continue;
|
|
17
|
-
if (cosineSimilarity(embeddings[i], embeddings[j]) >= INFERENCE_THRESHOLD) {
|
|
18
|
-
cluster.members.push(skills[j]);
|
|
19
|
-
cluster.indices.push(j);
|
|
20
|
-
assigned.add(j);
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
clusters.push(cluster);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return clusters;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function expandResults(annResults, db) {
|
|
31
|
-
if (!annResults || annResults.length === 0) return annResults;
|
|
32
|
-
|
|
33
|
-
const seenIds = new Set();
|
|
34
|
-
const expanded = [];
|
|
35
|
-
|
|
36
|
-
for (const r of annResults) {
|
|
37
|
-
if (!seenIds.has(r.skill_id)) {
|
|
38
|
-
seenIds.add(r.skill_id);
|
|
39
|
-
expanded.push(r);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(r.skill_id);
|
|
43
|
-
for (const c of callees) {
|
|
44
|
-
if (!seenIds.has(c.to_skill)) {
|
|
45
|
-
seenIds.add(c.to_skill);
|
|
46
|
-
expanded.push({ skill_id: c.to_skill, score: r.score * 0.85 });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
return expanded.sort((a, b) => b.score - a.score);
|
|
52
|
-
}
|
package/src/filter/dedup.js
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { cosineSimilarity } from '../../embedder.js';
|
|
2
|
-
|
|
3
|
-
const DEDUP_THRESHOLD = 0.97;
|
|
4
|
-
|
|
5
|
-
export function dedup(skills, embeddings) {
|
|
6
|
-
if (!skills.length) return { skills: [], embeddings: [] };
|
|
7
|
-
if (embeddings && embeddings.length !== skills.length) {
|
|
8
|
-
throw new Error('embeddings length must match skills length');
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const keep = [true]; // first skill always kept
|
|
12
|
-
const kept = [0];
|
|
13
|
-
|
|
14
|
-
for (let i = 1; i < skills.length; i++) {
|
|
15
|
-
let dup = false;
|
|
16
|
-
if (embeddings) {
|
|
17
|
-
for (const j of kept) {
|
|
18
|
-
if (cosineSimilarity(embeddings[i], embeddings[j]) >= DEDUP_THRESHOLD) {
|
|
19
|
-
dup = true;
|
|
20
|
-
break;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
if (dup) {
|
|
25
|
-
keep.push(false);
|
|
26
|
-
} else {
|
|
27
|
-
keep.push(true);
|
|
28
|
-
kept.push(i);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
skills: skills.filter((_, i) => keep[i]),
|
|
34
|
-
embeddings: embeddings ? embeddings.filter((_, i) => keep[i]) : undefined,
|
|
35
|
-
};
|
|
36
|
-
}
|