promptgraph-mcp 2.4.7 → 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.
Files changed (99) hide show
  1. package/README.md +3 -6
  2. package/ann.js +23 -51
  3. package/api.js +200 -0
  4. package/config.js +14 -0
  5. package/db.js +48 -0
  6. package/embedder.js +10 -0
  7. package/github-import.js +167 -27
  8. package/indexer.js +49 -12
  9. package/marketplace.js +130 -7
  10. package/package.json +9 -3
  11. package/search.js +86 -20
  12. package/src/filter/hard-filter.js +2 -0
  13. package/src/filter/train.js +3 -1
  14. package/src/reranker/reranker.js +27 -0
  15. package/src/store/flat-store.js +61 -0
  16. package/src/store/hnsw-store.js +187 -0
  17. package/src/store/index.js +19 -0
  18. package/src/store/vector-store.js +9 -0
  19. package/src/utils/rate-limiter.js +33 -0
  20. package/validator.js +31 -1
  21. package/registry/training/bad/accepts-HISTORY.md +0 -250
  22. package/registry/training/bad/argparse-CHANGELOG.md +0 -185
  23. package/registry/training/bad/balanced-match-LICENSE.md +0 -23
  24. package/registry/training/bad/better-sqlite3-README.md +0 -99
  25. package/registry/training/bad/bindings-LICENSE.md +0 -22
  26. package/registry/training/bad/bl-LICENSE.md +0 -13
  27. package/registry/training/bad/body-parser-README.md +0 -494
  28. package/registry/training/bad/bytes-HISTORY.md +0 -97
  29. package/registry/training/bad/camelcase-README.md +0 -135
  30. package/registry/training/bad/chai-README.md +0 -162
  31. package/registry/training/bad/cli-progress-LICENSE.md +0 -24
  32. package/registry/training/bad/content-type-HISTORY.md +0 -29
  33. package/registry/training/bad/cookie-SECURITY.md +0 -25
  34. package/registry/training/bad/cookie-signature-HISTORY.md +0 -70
  35. package/registry/training/bad/cors-README.md +0 -277
  36. package/registry/training/bad/cross-spawn-README.md +0 -89
  37. package/registry/training/bad/deep-extend-CHANGELOG.md +0 -46
  38. package/registry/training/bad/depd-HISTORY.md +0 -103
  39. package/registry/training/bad/esprima-README.md +0 -46
  40. package/registry/training/bad/etag-HISTORY.md +0 -83
  41. package/registry/training/bad/expect-type-SECURITY.md +0 -14
  42. package/registry/training/bad/finalhandler-HISTORY.md +0 -239
  43. package/registry/training/bad/fresh-HISTORY.md +0 -80
  44. package/registry/training/bad/glob-LICENSE.md +0 -63
  45. package/registry/training/bad/hono-README.md +0 -85
  46. package/registry/training/bad/http-errors-HISTORY.md +0 -186
  47. package/registry/training/bad/jose-LICENSE.md +0 -21
  48. package/registry/training/bad/jose-README.md +0 -153
  49. package/registry/training/bad/js-yaml-README.md +0 -299
  50. package/registry/training/bad/json-schema-typed-LICENSE.md +0 -57
  51. package/registry/training/bad/json-stringify-safe-CHANGELOG.md +0 -14
  52. package/registry/training/bad/lru-cache-LICENSE.md +0 -55
  53. package/registry/training/bad/media-typer-HISTORY.md +0 -50
  54. package/registry/training/bad/minimatch-LICENSE.md +0 -55
  55. package/registry/training/bad/minimist-CHANGELOG.md +0 -298
  56. package/registry/training/bad/minimist-README.md +0 -121
  57. package/registry/training/bad/ms-LICENSE.md +0 -21
  58. package/registry/training/bad/negotiator-HISTORY.md +0 -114
  59. package/registry/training/bad/on-finished-HISTORY.md +0 -98
  60. package/registry/training/bad/pkce-challenge-CHANGELOG.md +0 -114
  61. package/registry/training/bad/postcss-README.md +0 -28
  62. package/registry/training/bad/prebuild-install-CHANGELOG.md +0 -131
  63. package/registry/training/bad/proxy-addr-HISTORY.md +0 -161
  64. package/registry/training/bad/qs-CHANGELOG.md +0 -822
  65. package/registry/training/bad/rc-README.md +0 -227
  66. package/registry/training/bad/readable-stream-CONTRIBUTING.md +0 -38
  67. package/registry/training/bad/router-HISTORY.md +0 -228
  68. package/registry/training/bad/semver-README.md +0 -680
  69. package/registry/training/bad/send-README.md +0 -317
  70. package/registry/training/bad/serve-static-README.md +0 -253
  71. package/registry/training/bad/statuses-HISTORY.md +0 -87
  72. package/registry/training/bad/type-is-HISTORY.md +0 -292
  73. package/registry/training/bad/vary-HISTORY.md +0 -39
  74. package/registry/training/bad/vite-LICENSE.md +0 -2230
  75. package/registry/training/bad/which-CHANGELOG.md +0 -166
  76. package/registry/training/bad/zod-README.md +0 -191
  77. package/registry/training/good/skills-store-autopilot.md +0 -85
  78. package/registry/training/good/skills-store-bot-builder.md +0 -70
  79. package/registry/training/good/skills-store-chain.md +0 -136
  80. package/registry/training/good/skills-store-evolve.md +0 -100
  81. package/registry/training/good/skills-store-game.md +0 -27
  82. package/registry/training/good/skills-store-hunt.md +0 -102
  83. package/registry/training/good/skills-store-intel.md +0 -56
  84. package/registry/training/good/skills-store-memory-gc.md +0 -58
  85. package/registry/training/good/skills-store-pcsort.md +0 -207
  86. package/registry/training/good/skills-store-pickup.md +0 -60
  87. package/registry/training/good/skills-store-recon.md +0 -141
  88. package/registry/training/good/skills-store-remember.md +0 -64
  89. package/registry/training/good/skills-store-report.md +0 -117
  90. package/registry/training/good/skills-store-router.md +0 -225
  91. package/registry/training/good/skills-store-search.md +0 -168
  92. package/registry/training/good/skills-store-surface.md +0 -53
  93. package/registry/training/good/skills-store-token-scan.md +0 -141
  94. package/registry/training/good/skills-store-triage.md +0 -97
  95. package/registry/training/good/skills-store-unity.md +0 -733
  96. package/registry/training/good/skills-store-validate.md +0 -135
  97. package/registry/training/good/skills-store-web3-audit.md +0 -217
  98. package/src/filter/cluster.js +0 -52
  99. package/src/filter/dedup.js +0 -36
package/marketplace.js CHANGED
@@ -42,6 +42,42 @@ const _require = createRequire(import.meta.url);
42
42
  const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
43
43
  const UA = `promptgraph-mcp/${PKG_VERSION}`;
44
44
 
45
+ const REGISTRY_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
46
+
47
+ function validateRegistryEntry(item) {
48
+ const errors = [];
49
+ if (!item.id || typeof item.id !== 'string') errors.push('missing required id');
50
+ else if (!REGISTRY_ID_RE.test(item.id)) errors.push(`invalid id "${item.id}" (must match /^[a-z0-9][a-z0-9-]{1,63}$/)`);
51
+ if (!item.name || typeof item.name !== 'string') errors.push('missing required name');
52
+ else if (item.name.length < 3) errors.push(`name "${item.name}" too short (min 3 chars)`);
53
+ if (!item.description || typeof item.description !== 'string') errors.push('missing required description');
54
+ else if (item.description.length < 15) errors.push(`description too short (min 15 chars)`);
55
+ if (item.version !== undefined && typeof item.version !== 'string') errors.push('version must be a string');
56
+ if (item.author !== undefined && typeof item.author !== 'string') errors.push('author must be a string');
57
+ if (item.license !== undefined && typeof item.license !== 'string') errors.push('license must be a string');
58
+ if (item.updated_at !== undefined) {
59
+ if (typeof item.updated_at !== 'string') errors.push('updated_at must be a string');
60
+ else if (isNaN(Date.parse(item.updated_at))) errors.push(`updated_at "${item.updated_at}" is not a valid ISO date`);
61
+ }
62
+ if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
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
+ }
74
+ if (errors.length > 0) {
75
+ console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
76
+ return { ok: false };
77
+ }
78
+ return { ok: true };
79
+ }
80
+
45
81
  // Deterministic short code from an id. Same id always yields the same code,
46
82
  // so codes auto-generate — no need to assign them by hand.
47
83
  export function codeFor(id) {
@@ -136,7 +172,9 @@ export async function browseMarketplace(topK = 20) {
136
172
  const registry = JSON.parse(text);
137
173
  if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
138
174
  return registry.skills
139
- .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) }))
176
+ .filter(s => validateRegistryEntry(s).ok)
177
+ .filter(s => s.raw_url)
140
178
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
141
179
  .slice(0, topK);
142
180
  } catch (e) {
@@ -154,6 +192,10 @@ export async function installSkillFromUrl(url) {
154
192
  // derive filename from URL
155
193
  const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
156
194
  const dest = path.join(SKILLS_DIR, urlName.endsWith('.md') ? urlName : urlName + '.md');
195
+ const resolvedDest = path.resolve(dest);
196
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
197
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
198
+ }
157
199
  const v = writeSkillAtomic(dest, content);
158
200
  if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
159
201
  return { success: true, path: dest, url: rawUrl };
@@ -172,8 +214,9 @@ export async function installSkill(query) {
172
214
  const text = await fetchText(REGISTRY_URL);
173
215
  const registry = JSON.parse(text);
174
216
  const q = String(query).trim().toLowerCase();
217
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
175
218
  // match by code (stored OR auto-generated), id, or name
176
- const skill = registry.skills?.find(s =>
219
+ const skill = validSkills.find(s =>
177
220
  (s.code || codeFor(s.id)).toLowerCase() === q ||
178
221
  s.id?.toLowerCase() === q ||
179
222
  s.name?.toLowerCase() === q
@@ -191,6 +234,10 @@ export async function installSkill(query) {
191
234
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
192
235
  ensureMarketplaceSource();
193
236
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
237
+ const resolvedDest = path.resolve(dest);
238
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
239
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
240
+ }
194
241
 
195
242
  const content = await fetchText(skill.raw_url);
196
243
  const v = writeSkillAtomic(dest, content);
@@ -222,7 +269,7 @@ export async function browseBundles(topK = 20) {
222
269
  try {
223
270
  const text = await fetchText(REGISTRY_URL);
224
271
  const registry = JSON.parse(text);
225
- const bundles = registry.bundles || [];
272
+ const bundles = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok);
226
273
  const cache = readSkillCountCache();
227
274
  const now = Date.now();
228
275
  let changed = false;
@@ -250,7 +297,7 @@ export async function browseBundles(topK = 20) {
250
297
 
251
298
  return bundles
252
299
  .filter(b => !b.repo_url || b.skillCount > 0)
253
- .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
300
+ .map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
254
301
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
255
302
  .slice(0, topK);
256
303
  } catch (e) {
@@ -273,7 +320,8 @@ export async function installBundle(bundleId) {
273
320
  const text = await fetchText(REGISTRY_URL);
274
321
  const registry = JSON.parse(text);
275
322
  const q = String(bundleId).trim().toLowerCase();
276
- const bundle = (registry.bundles || []).find(b =>
323
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
324
+ const bundle = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok).find(b =>
277
325
  (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
278
326
  );
279
327
  if (!bundle) return { error: `No bundle matching "${bundleId}"` };
@@ -290,12 +338,16 @@ export async function installBundle(bundleId) {
290
338
 
291
339
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
292
340
  for (const skillId of bundle.skills || []) {
293
- const skill = registry.skills?.find(s => s.id === skillId);
341
+ const skill = validSkills.find(s => s.id === skillId);
294
342
  if (!skill?.raw_url) { failed.push(skillId); continue; }
295
343
  try {
296
344
  if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
297
345
  const content = await fetchText(skill.raw_url);
298
346
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
347
+ const resolvedDest = path.resolve(dest);
348
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
349
+ failed.push(skillId); continue;
350
+ }
299
351
  const v = writeSkillAtomic(dest, content);
300
352
  if (!v.ok) { failed.push(skillId); continue; }
301
353
  installed.push(skillId);
@@ -478,7 +530,7 @@ export function pruneInvalidRepos() {
478
530
  continue;
479
531
  }
480
532
 
481
- const mdFiles = globSync(`${src.dir}/**/*.md`);
533
+ const mdFiles = globSync(`${src.dir}/**/*.md`).map(fp => path.resolve(fp));
482
534
  if (mdFiles.length === 0) {
483
535
  removed.push({ repo: repoName, reason: 'no .md files' });
484
536
  config.sources = config.sources.filter(s => s !== src);
@@ -506,3 +558,74 @@ export function pruneInvalidRepos() {
506
558
  saveConfig(config);
507
559
  return { removed, kept, errors };
508
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,11 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.4.7",
3
+ "version": "2.5.0",
4
+ "files": [
5
+ "*.js",
6
+ "src/**/*.js",
7
+ "README.md"
8
+ ],
4
9
  "main": "index.js",
5
10
  "type": "module",
6
11
  "bin": {
@@ -42,13 +47,14 @@
42
47
  "fastembed": "^2.1.0",
43
48
  "glob": "^13.0.6",
44
49
  "gray-matter": "^4.0.3",
50
+ "hnswlib-node": "^3.0.0",
45
51
  "ora": "^9.4.0"
46
52
  },
47
53
  "devDependencies": {
54
+ "@vitest/coverage-v8": "^4.1.8",
48
55
  "vitest": "^4.1.8"
49
56
  },
50
57
  "overrides": {
51
- "tar": "^6.2.1",
52
- "js-yaml": "^4.1.0"
58
+ "tar": "^7.5.11"
53
59
  }
54
60
  }
package/search.js CHANGED
@@ -2,6 +2,12 @@ import { embed, cosineSimilarity } from './embedder.js';
2
2
  import { getDb, blobToVec } from './db.js';
3
3
  import { annSearch } from './ann.js';
4
4
 
5
+ function getHybridWeights(query) {
6
+ const hasTechTerms = /[A-Z]|\d/.test(query);
7
+ if (hasTechTerms) return { embedWeight: 0.5, bm25Weight: 0.5 }
8
+ return { embedWeight: 0.7, bm25Weight: 0.3 }
9
+ }
10
+
5
11
  function applyRatingBoost(db, id, score) {
6
12
  const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
7
13
  if (r && (r.success + r.fail) > 3) {
@@ -11,13 +17,13 @@ function applyRatingBoost(db, id, score) {
11
17
  return score;
12
18
  }
13
19
 
14
- export async function search(query, topK = 5) {
15
- const db = getDb();
16
- const queryVec = await embed(query);
17
-
18
- // Try ANN index first (fast, O(log N))
19
- const annResults = await annSearch(queryVec, topK * 4);
20
+ function normalizeBM25(raw) {
21
+ return Math.max(0, 1 + raw / 10);
22
+ }
20
23
 
24
+ async function runEmbeddingSearch(db, queryVec, topK) {
25
+ // Try ANN index first (fast)
26
+ const annResults = await annSearch(queryVec, topK);
21
27
  if (annResults && annResults.length > 0) {
22
28
  const bestBySkill = new Map();
23
29
  for (const r of annResults) {
@@ -25,11 +31,8 @@ export async function search(query, topK = 5) {
25
31
  if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
26
32
  }
27
33
  return [...bestBySkill.entries()]
28
- .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
29
- .sort((a, b) => b.score - a.score)
30
- .slice(0, topK)
31
- .map(({ id, score }) => skillWithSnippet(db, id, score))
32
- .filter(Boolean);
34
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
35
+ .sort((a, b) => b.score - a.score);
33
36
  }
34
37
 
35
38
  // Fallback: brute force cosine (used before first reindex)
@@ -42,14 +45,14 @@ export async function search(query, topK = 5) {
42
45
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
43
46
  }
44
47
  return [...bestBySkill.entries()]
45
- .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
46
- .sort((a, b) => b.score - a.score)
47
- .slice(0, topK)
48
- .map(({ id, score }) => skillWithSnippet(db, id, score))
49
- .filter(Boolean);
48
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
49
+ .sort((a, b) => b.score - a.score);
50
50
  }
51
51
 
52
- // Fast-mode fallback: FTS5 keyword search (no embeddings)
52
+ return [];
53
+ }
54
+
55
+ function runBM25Search(db, query, topK) {
53
56
  try {
54
57
  const terms = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(Boolean).join(' OR ');
55
58
  const rows = db.prepare(
@@ -57,14 +60,77 @@ export async function search(query, topK = 5) {
57
60
  JOIN skills s ON skills_fts.id = s.id
58
61
  WHERE skills_fts MATCH ? ORDER BY score LIMIT ?`
59
62
  ).all(terms, topK);
60
- return rows.map(r => skillWithSnippet(db, r.id, Math.max(0, 1 + r.score / 10))).filter(Boolean);
63
+ return rows.map(r => ({ id: r.id, score: normalizeBM25(r.score) }));
61
64
  } catch {
62
65
  return [];
63
66
  }
64
67
  }
65
68
 
69
+ export async function search(query, topK = 5) {
70
+ const db = getDb();
71
+ const { embedWeight, bm25Weight } = getHybridWeights(query);
72
+ const queryVec = await embed(query);
73
+
74
+ const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
75
+ const bm25Results = runBM25Search(db, query, topK * 4);
76
+
77
+ // Fallback: pure BM25 if embeddings unavailable
78
+ if (!embedResults.length) {
79
+ return bm25Results.slice(0, topK)
80
+ .map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score)))
81
+ .filter(Boolean);
82
+ }
83
+
84
+ // Fallback: pure embedding if BM25 returns nothing
85
+ if (!bm25Results.length) {
86
+ return embedResults.slice(0, topK)
87
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
88
+ .filter(Boolean);
89
+ }
90
+
91
+ // Hybrid: combine normalized scores from both signals
92
+ const combined = new Map();
93
+ for (const { id, score } of embedResults) {
94
+ combined.set(id, { embedScore: score, bm25Score: 0 });
95
+ }
96
+ for (const { id, score } of bm25Results) {
97
+ const entry = combined.get(id);
98
+ if (entry) {
99
+ entry.bm25Score = score;
100
+ } else {
101
+ combined.set(id, { embedScore: 0, bm25Score: score });
102
+ }
103
+ }
104
+
105
+ const ordered = [...combined.entries()]
106
+ .map(([id, s]) => ({
107
+ id,
108
+ score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
109
+ }))
110
+ .sort((a, b) => b.score - a.score)
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)
128
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
129
+ .filter(Boolean);
130
+ }
131
+
66
132
  function skillWithSnippet(db, id, score) {
67
- const skill = db.prepare('SELECT id, name, description, path, source, content FROM skills WHERE id = ?').get(id);
133
+ const skill = db.prepare('SELECT id, name, description, path, source, content, version, author, license, updated_at, downloads, verified FROM skills WHERE id = ?').get(id);
68
134
  if (!skill) return null;
69
135
  const { content, ...rest } = skill;
70
136
  const snippet = content?.replace(/^---[\s\S]*?---\n?/, '').trim().slice(0, 400) || '';
@@ -131,5 +197,5 @@ export function getImpact(nameOrId) {
131
197
 
132
198
  export function listAll() {
133
199
  const db = getDb();
134
- return db.prepare('SELECT id, name, description, source FROM skills ORDER BY source, name').all();
200
+ return db.prepare('SELECT id, name, description, source, version, author, license, updated_at, downloads, verified FROM skills ORDER BY source, name').all();
135
201
  }
@@ -19,6 +19,8 @@ const SKIP_DIRS = new Set([
19
19
  'tests', 'test', '__tests__', 'spec', 'fixtures', 'assets', 'images',
20
20
  'img', 'screenshots', 'media', 'static', 'public', 'dist', 'build',
21
21
  'node_modules', 'vendor', 'third_party',
22
+ 'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
23
+ 'cheatsheets', 'resources',
22
24
  ]);
23
25
 
24
26
  const BADGE_RE = /!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i;
@@ -1,10 +1,12 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { fileURLToPath } from 'url';
4
5
  import { globSync } from 'glob';
5
6
  import { embedBatch } from '../../embedder.js';
6
7
 
7
- const TRAINING_DIR = path.resolve(new URL('../../registry/training', import.meta.url).pathname);
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const TRAINING_DIR = path.resolve(__dirname, '../../registry/training');
8
10
  const MODEL_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'model.json');
9
11
 
10
12
  function readAllMd(dir) {
@@ -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,61 @@
1
+ import { VectorStore } from './vector-store.js';
2
+
3
+ export class FlatVectorStore extends VectorStore {
4
+ constructor() {
5
+ super();
6
+ this._items = [];
7
+ }
8
+
9
+ async add(id, vector) {
10
+ this._items.push({ skill_id: id, vec: new Float32Array(vector) });
11
+ }
12
+
13
+ async addBatch(entries) {
14
+ for (const { id, vector } of entries) {
15
+ this._items.push({ skill_id: id, vec: new Float32Array(vector) });
16
+ }
17
+ }
18
+
19
+ async remove(id) {
20
+ this._items = this._items.filter(item => item.skill_id !== id);
21
+ }
22
+
23
+ async search(vector, topK = 20) {
24
+ const qArr = new Float32Array(vector);
25
+ const bestBySkill = new Map();
26
+ for (const entry of this._items) {
27
+ const score = cosineSim(qArr, entry.vec);
28
+ const prev = bestBySkill.get(entry.skill_id);
29
+ if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
30
+ }
31
+ return [...bestBySkill.entries()]
32
+ .sort((a, b) => b[1] - a[1])
33
+ .slice(0, topK)
34
+ .map(([skill_id, score]) => ({ skill_id, score }));
35
+ }
36
+
37
+ async build(entries) {
38
+ this._items = [];
39
+ for (const { skill_id, vector } of entries) {
40
+ this._items.push({ skill_id, vec: new Float32Array(vector) });
41
+ }
42
+ }
43
+
44
+ async clear() {
45
+ this._items = [];
46
+ }
47
+
48
+ get size() {
49
+ return this._items.length;
50
+ }
51
+ }
52
+
53
+ function cosineSim(a, b) {
54
+ let dot = 0, na = 0, nb = 0;
55
+ for (let i = 0; i < a.length; i++) {
56
+ dot += a[i] * b[i];
57
+ na += a[i] * a[i];
58
+ nb += b[i] * b[i];
59
+ }
60
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
61
+ }