promptgraph-mcp 2.5.0 → 2.6.1

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/marketplace.js CHANGED
@@ -561,9 +561,38 @@ export function pruneInvalidRepos() {
561
561
 
562
562
  // ── Trust level system ─────────────────────────────────────────────────────────
563
563
  const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
564
+ const TRUST_LEVEL_BOOST = { verified: 1.15, official: 1.10, trusted: 1.05, community: 1.0, unknown: 0.95 }
565
+
566
+ // Popularity = log(downloads+1) × (rating+1), then decayed by age (days since last_update, halved every 180 days)
567
+ function calcPopularity(downloads = 0, rating = 0, lastUpdateStr = null) {
568
+ const logDownloads = Math.log10((downloads || 0) + 1)
569
+ const ratingFactor = ((rating || 0) + 1) / 6 // normalised to 0–1
570
+ let pop = logDownloads * ratingFactor * 100
571
+
572
+ if (lastUpdateStr) {
573
+ const daysSince = (Date.now() - new Date(lastUpdateStr + 'Z').getTime()) / 86400000
574
+ if (daysSince > 0) {
575
+ pop *= Math.pow(0.5, daysSince / 180) // half-life 180 days
576
+ }
577
+ }
578
+ return Math.round(pop * 100) / 100
579
+ }
564
580
 
565
- function calcPopularity(downloads = 0, rating = 0) {
566
- return Math.round((downloads || 0) * ((rating || 0) + 1) * 100) / 100
581
+ // Auto-promote threshold: downloads at which a skill graduates to the next trust level
582
+ const AUTO_PROMOTE_THRESHOLDS = [
583
+ { minDownloads: 10000, level: 'verified' },
584
+ { minDownloads: 1000, level: 'official' },
585
+ { minDownloads: 100, level: 'trusted' },
586
+ ]
587
+
588
+ function autoPromote(downloads, currentLevel) {
589
+ const rank = VALID_TRUST_LEVELS.indexOf(currentLevel || 'unknown')
590
+ for (const t of AUTO_PROMOTE_THRESHOLDS) {
591
+ if (downloads >= t.minDownloads && VALID_TRUST_LEVELS.indexOf(t.level) > rank) {
592
+ return t.level
593
+ }
594
+ }
595
+ return null
567
596
  }
568
597
 
569
598
  export async function setTrustLevel(name, level) {
@@ -586,28 +615,36 @@ export async function getByTrustLevel(level) {
586
615
  return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
587
616
  }
588
617
  if (level) {
589
- return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY id').all(level)
618
+ return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY downloads DESC, popularity DESC').all(level)
590
619
  }
591
- return db.prepare('SELECT * FROM registry_entries ORDER BY id').all()
620
+ return db.prepare('SELECT * FROM registry_entries ORDER BY downloads DESC, popularity DESC').all()
592
621
  }
593
622
 
594
623
  export async function incrementDownloads(name) {
595
624
  const db = getDb()
596
625
  const id = String(name)
597
- const existing = db.prepare('SELECT downloads, rating FROM registry_entries WHERE id = ?').get(id)
626
+ const existing = db.prepare('SELECT downloads, rating, last_update, trust_level FROM registry_entries WHERE id = ?').get(id)
598
627
  if (existing) {
599
628
  const newDownloads = (existing.downloads || 0) + 1
600
- const pop = calcPopularity(newDownloads, existing.rating || 0)
629
+ const pop = calcPopularity(newDownloads, existing.rating || 0, existing.last_update)
601
630
  db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
602
631
  .run(newDownloads, pop, id)
632
+
633
+ // Auto-promote if threshold crossed
634
+ const newLevel = autoPromote(newDownloads, existing.trust_level)
635
+ if (newLevel) {
636
+ db.prepare('UPDATE registry_entries SET trust_level = ? WHERE id = ?').run(newLevel, id)
637
+ }
603
638
  } else {
604
639
  const pop = calcPopularity(1, 0)
605
- db.prepare('INSERT INTO registry_entries (id, downloads, popularity, last_update) VALUES (?, 1, ?, datetime(\'now\'))')
640
+ db.prepare('INSERT INTO registry_entries (id, downloads, popularity, trust_level, last_update) VALUES (?, 1, ?, \'unknown\', datetime(\'now\'))')
606
641
  .run(id, pop)
607
642
  }
608
643
  return { ok: true }
609
644
  }
610
645
 
646
+ export { VALID_TRUST_LEVELS, TRUST_LEVEL_BOOST, calcPopularity, autoPromote }
647
+
611
648
  export async function rateSkill(name, rating) {
612
649
  const db = getDb()
613
650
  if (typeof rating !== 'number' || rating < 0 || rating > 5) {
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "files": [
5
5
  "*.js",
6
- "src/**/*.js",
6
+ "commands/",
7
+ "src/",
7
8
  "README.md"
8
9
  ],
9
10
  "main": "index.js",
package/search.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { embed, cosineSimilarity } from './embedder.js';
2
2
  import { getDb, blobToVec } from './db.js';
3
3
  import { annSearch } from './ann.js';
4
+ import { TRUST_LEVEL_BOOST } from './marketplace.js';
4
5
 
5
6
  function getHybridWeights(query) {
6
7
  const hasTechTerms = /[A-Z]|\d/.test(query);
@@ -10,11 +11,25 @@ function getHybridWeights(query) {
10
11
 
11
12
  function applyRatingBoost(db, id, score) {
12
13
  const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
14
+ let boost = 1.0;
13
15
  if (r && (r.success + r.fail) > 3) {
14
- const rating = r.success / (r.success + r.fail);
15
- return score * (0.85 + 0.15 * rating);
16
+ boost *= (0.85 + 0.15 * (r.success / (r.success + r.fail)));
16
17
  }
17
- return score;
18
+
19
+ // Trust level boost (verified/official/trusted rank higher)
20
+ const re = db.prepare('SELECT trust_level, popularity FROM registry_entries WHERE id = ?').get(id);
21
+ if (re) {
22
+ boost *= (TRUST_LEVEL_BOOST[re.trust_level] || 1.0);
23
+ // Popularity bump: top 20% of skills get +5%, rest unchanged
24
+ if (re.popularity > 0) {
25
+ const top20 = db.prepare('SELECT popularity FROM registry_entries ORDER BY popularity DESC LIMIT 1').get();
26
+ if (top20 && re.popularity >= top20.popularity * 0.2) {
27
+ boost *= 1.05;
28
+ }
29
+ }
30
+ }
31
+
32
+ return score * boost;
18
33
  }
19
34
 
20
35
  function normalizeBM25(raw) {
@@ -114,13 +129,13 @@ export async function search(query, topK = 5) {
114
129
  if (rerankerEnabled) {
115
130
  const { Reranker } = await import('./src/reranker/reranker.js')
116
131
  const reranker = new Reranker()
117
- const topN = ordered.slice(0, 20)
132
+ const topN = ordered.slice(0, Math.max(50, topK * 6))
118
133
  .map(({ id, score }) => {
119
134
  const s = skillWithSnippet(db, id, score)
120
135
  return s ? { id, text: s.snippet, score } : null
121
136
  })
122
137
  .filter(Boolean)
123
- const reranked = await reranker.rerank(query, topN)
138
+ const reranked = await reranker.rerank(query, topN, topK)
124
139
  return reranked.map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score))).filter(Boolean)
125
140
  }
126
141
 
@@ -1,27 +1,92 @@
1
+ // Term-overlap reranker with phrase matching and IDF-like weighting.
2
+ // Replace `rerank()` with a cross-encoder (BGE Reranker / MiniLM via ONNX)
3
+ // for deeper semantic reranking. This class is the plug-in point.
1
4
  export class Reranker {
2
5
  constructor(modelName = 'default', device = 'cpu') {
3
6
  this.modelName = modelName
4
7
  this.device = device
5
8
  }
6
9
 
7
- // Cross-encoder style reranker
8
- // Plug-in point for BGE Reranker / MiniLM Reranker via ONNX
9
- async rerank(query, results) {
10
+ rerank(query, results, topK = 5) {
10
11
  if (!results || results.length === 0) return []
11
12
 
12
- const queryTerms = (query.toLowerCase().match(/\w+/g) || [])
13
- const queryTermCount = queryTerms.length || 1
13
+ const rawTerms = (query.toLowerCase().match(/\w+/g) || [])
14
+ if (rawTerms.length === 0) return results.slice(0, topK)
14
15
 
15
- const scored = results.map(r => {
16
- const text = (r.text || '').toLowerCase()
17
- const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
16
+ const unigrams = rawTerms
17
+ const bigrams = []
18
+ const trigrams = []
19
+ for (let i = 0; i < rawTerms.length - 1; i++) bigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1])
20
+ for (let i = 0; i < rawTerms.length - 2; i++) trigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1] + ' ' + rawTerms[i + 2])
21
+
22
+ const texts = results.map(r => (r.text || '').toLowerCase())
23
+
24
+ // Term doc-frequency
25
+ const termDf = {}
26
+ for (const t of unigrams) {
27
+ termDf[t] = texts.filter(txt => txt.includes(t)).length
28
+ }
29
+ const nResults = results.length
30
+
31
+ // First pass: compute raw tfIdf for each result
32
+ const tfIdfValues = texts.map(text => {
33
+ let sum = 0
34
+ for (const term of unigrams) {
35
+ const df = termDf[term] || 1
36
+ const idf = 1 + Math.log10((nResults + 1) / df)
37
+ let idx = -1
38
+ let count = 0
39
+ while ((idx = text.indexOf(term, idx + 1)) !== -1) count++
40
+ sum += count * idf
41
+ }
42
+ return sum
43
+ })
44
+
45
+ const maxTfIdf = Math.max(...tfIdfValues, 1)
46
+
47
+ const scored = results.map((r, idx) => {
48
+ const text = texts[idx]
49
+ const lines = text.split('\n')
50
+
51
+ // 1. N-gram overlap
52
+ const unigramMatch = unigrams.filter(t => text.includes(t)).length / unigrams.length
53
+ const bigramMatch = bigrams.length > 0 ? bigrams.filter(b => text.includes(b)).length / bigrams.length : 0
54
+ const trigramMatch = trigrams.length > 0 ? trigrams.filter(t => text.includes(t)).length / trigrams.length : 0
55
+ const ngramScore = 0.4 * unigramMatch + 0.35 * bigramMatch + 0.25 * trigramMatch
56
+
57
+ // 2. IDF-weighted TF (normalised by max across results)
58
+ const tfIdfScore = tfIdfValues[idx] / maxTfIdf
59
+
60
+ // 3. Exact phrase proximity — consecutive same-order term matches
61
+ let phraseBoost = 0
62
+ if (rawTerms.length >= 2) {
63
+ const textWords = text.split(/\s+/)
64
+ let bestRun = 0
65
+ for (let i = 0; i < textWords.length - rawTerms.length + 1; i++) {
66
+ let matchLen = 0
67
+ for (let j = 0; j < rawTerms.length; j++) {
68
+ if (textWords[i + j] === rawTerms[j]) matchLen++
69
+ else break
70
+ }
71
+ if (matchLen >= 2) bestRun = Math.max(bestRun, matchLen)
72
+ }
73
+ phraseBoost = bestRun / rawTerms.length
74
+ }
75
+
76
+ // 4. Header position boost
77
+ const headerText = lines.slice(0, 3).join(' ').toLowerCase()
78
+ const headerOverlap = unigrams.filter(t => headerText.includes(t)).length / unigrams.length
79
+
80
+ // Blend
81
+ const overlapScore = 0.25 * ngramScore + 0.25 * tfIdfScore + 0.25 * phraseBoost + 0.15 * headerOverlap + 0.10 * unigramMatch
18
82
 
19
83
  return {
20
- ...r,
21
- score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
84
+ id: r.id,
85
+ text: r.text,
86
+ score: 0.70 * (r.score || 0) + 0.30 * overlapScore,
22
87
  }
23
88
  })
24
89
 
25
- return scored.sort((a, b) => b.score - a.score).slice(0, 5)
90
+ return scored.sort((a, b) => b.score - a.score).slice(0, topK)
26
91
  }
27
92
  }
@@ -7,7 +7,7 @@ export function getStore(type = null) {
7
7
  if (_store) return _store;
8
8
 
9
9
  if (!type) {
10
- type = process.env.PG_VECTOR_STORE || 'flat';
10
+ type = process.env.PG_VECTOR_STORE || 'hnsw';
11
11
  }
12
12
 
13
13
  _store = type === 'hnsw' ? new HNSWVectorStore() : new FlatVectorStore();