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/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, BATCH_SIZE } from './embedder.js';
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';
@@ -18,7 +19,7 @@ function sanitizePath(filePath) {
18
19
  return path.resolve(filePath);
19
20
  }
20
21
 
21
- async function indexBatch(db, skills, { fast = false } = {}) {
22
+ export async function indexBatch(db, skills, { fast = false } = {}) {
22
23
  const upsertSkill = db.prepare(`
23
24
  INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
24
25
  VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
@@ -106,21 +107,15 @@ export async function indexAll({ fast = false } = {}) {
106
107
  normDir: path.resolve(s.dir),
107
108
  })).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
108
109
 
109
- console.error('DEBUG normalizedSources count:', normalizedSources.length);
110
- console.error('DEBUG first dir:', normalizedSources[0]?.dir);
111
-
112
110
  const seenFiles = new Set();
113
111
  const allFiles = [];
114
112
  for (const { dir, source } of normalizedSources) {
115
113
  const files = globSync(`${dir}/**/*.md`);
116
- console.error('DEBUG source:', source, 'dir:', dir, 'count:', files.length);
117
114
  for (const f of files) {
118
115
  const norm = sanitizePath(f);
119
116
  if (!seenFiles.has(norm)) {
120
117
  seenFiles.add(norm);
121
118
  allFiles.push({ file: norm, source });
122
- } else {
123
- console.error('DEBUG duplicate:', norm);
124
119
  }
125
120
  }
126
121
  if (allFiles.length > MAX_FILE_COUNT) {
@@ -129,7 +124,6 @@ export async function indexAll({ fast = false } = {}) {
129
124
  }
130
125
  }
131
126
  const total = allFiles.length;
132
- console.error('DEBUG total files found:', total);
133
127
  info(`Found ${chalk.white.bold(total)} files`);
134
128
 
135
129
  // reconcile: remove skills whose files no longer exist OR whose name changed
@@ -210,6 +204,7 @@ export async function indexAll({ fast = false } = {}) {
210
204
  batch = [];
211
205
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
212
206
  progress(count, total, { skipped, eta, errors });
207
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
213
208
  }
214
209
  } catch (e) {
215
210
  errors++;
@@ -301,6 +296,7 @@ export async function indexSource(dir, sourceName) {
301
296
  await indexBatch(db, batch);
302
297
  count += batch.length; batch = [];
303
298
  progress(count, total, { skipped, errors });
299
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
304
300
  }
305
301
  } catch (e) { errors++; count++; }
306
302
  }
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 => ({ ...s, code: s.code || codeFor(s.id) })) // auto-fill code
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 => ({ ...b, code: b.code || codeFor(b.id) }))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.4.8",
3
+ "version": "2.6.0",
4
4
  "files": [
5
5
  "*.js",
6
6
  "src/**/*.js",
@@ -55,6 +55,6 @@
55
55
  "vitest": "^4.1.8"
56
56
  },
57
57
  "overrides": {
58
- "tar": "^6.2.1"
58
+ "tar": "^7.5.11"
59
59
  }
60
60
  }
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
- let embedWeight = 0.7;
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
- embedWeight = 0.5;
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
- adaptWeights(query);
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
- return [...combined.entries()]
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
- .slice(0, topK)
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,28 @@
1
+ export class Reranker {
2
+ constructor(modelName = 'default', device = 'cpu') {
3
+ this.modelName = modelName
4
+ this.device = device
5
+ }
6
+
7
+ // Term-overlap boost on top of hybrid search scores
8
+ // This is a lightweight reranker that ranks by term overlap ratio.
9
+ // Replace with BGE Reranker / MiniLM cross-encoder via ONNX for deeper semantic reranking.
10
+ async rerank(query, results) {
11
+ if (!results || results.length === 0) return []
12
+
13
+ const queryTerms = (query.toLowerCase().match(/\w+/g) || []).slice(0, 50)
14
+ const queryTermCount = queryTerms.length || 1
15
+
16
+ const scored = results.map(r => {
17
+ const text = (r.text || '').toLowerCase()
18
+ const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
19
+
20
+ return {
21
+ ...r,
22
+ score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
23
+ }
24
+ })
25
+
26
+ return scored.sort((a, b) => b.score - a.score).slice(0, 5)
27
+ }
28
+ }
@@ -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];
@@ -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
- }
@@ -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
- }