promptgraph-mcp 2.4.7 → 2.4.8

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 (95) hide show
  1. package/README.md +3 -6
  2. package/ann.js +23 -51
  3. package/api.js +200 -0
  4. package/config.js +7 -0
  5. package/db.js +20 -0
  6. package/embedder.js +10 -0
  7. package/github-import.js +102 -22
  8. package/indexer.js +45 -11
  9. package/marketplace.js +47 -5
  10. package/package.json +9 -3
  11. package/search.js +84 -20
  12. package/src/filter/hard-filter.js +2 -0
  13. package/src/filter/train.js +3 -1
  14. package/src/store/flat-store.js +61 -0
  15. package/src/store/hnsw-store.js +187 -0
  16. package/src/store/index.js +19 -0
  17. package/src/store/vector-store.js +9 -0
  18. package/validator.js +7 -1
  19. package/registry/training/bad/accepts-HISTORY.md +0 -250
  20. package/registry/training/bad/argparse-CHANGELOG.md +0 -185
  21. package/registry/training/bad/balanced-match-LICENSE.md +0 -23
  22. package/registry/training/bad/better-sqlite3-README.md +0 -99
  23. package/registry/training/bad/bindings-LICENSE.md +0 -22
  24. package/registry/training/bad/bl-LICENSE.md +0 -13
  25. package/registry/training/bad/body-parser-README.md +0 -494
  26. package/registry/training/bad/bytes-HISTORY.md +0 -97
  27. package/registry/training/bad/camelcase-README.md +0 -135
  28. package/registry/training/bad/chai-README.md +0 -162
  29. package/registry/training/bad/cli-progress-LICENSE.md +0 -24
  30. package/registry/training/bad/content-type-HISTORY.md +0 -29
  31. package/registry/training/bad/cookie-SECURITY.md +0 -25
  32. package/registry/training/bad/cookie-signature-HISTORY.md +0 -70
  33. package/registry/training/bad/cors-README.md +0 -277
  34. package/registry/training/bad/cross-spawn-README.md +0 -89
  35. package/registry/training/bad/deep-extend-CHANGELOG.md +0 -46
  36. package/registry/training/bad/depd-HISTORY.md +0 -103
  37. package/registry/training/bad/esprima-README.md +0 -46
  38. package/registry/training/bad/etag-HISTORY.md +0 -83
  39. package/registry/training/bad/expect-type-SECURITY.md +0 -14
  40. package/registry/training/bad/finalhandler-HISTORY.md +0 -239
  41. package/registry/training/bad/fresh-HISTORY.md +0 -80
  42. package/registry/training/bad/glob-LICENSE.md +0 -63
  43. package/registry/training/bad/hono-README.md +0 -85
  44. package/registry/training/bad/http-errors-HISTORY.md +0 -186
  45. package/registry/training/bad/jose-LICENSE.md +0 -21
  46. package/registry/training/bad/jose-README.md +0 -153
  47. package/registry/training/bad/js-yaml-README.md +0 -299
  48. package/registry/training/bad/json-schema-typed-LICENSE.md +0 -57
  49. package/registry/training/bad/json-stringify-safe-CHANGELOG.md +0 -14
  50. package/registry/training/bad/lru-cache-LICENSE.md +0 -55
  51. package/registry/training/bad/media-typer-HISTORY.md +0 -50
  52. package/registry/training/bad/minimatch-LICENSE.md +0 -55
  53. package/registry/training/bad/minimist-CHANGELOG.md +0 -298
  54. package/registry/training/bad/minimist-README.md +0 -121
  55. package/registry/training/bad/ms-LICENSE.md +0 -21
  56. package/registry/training/bad/negotiator-HISTORY.md +0 -114
  57. package/registry/training/bad/on-finished-HISTORY.md +0 -98
  58. package/registry/training/bad/pkce-challenge-CHANGELOG.md +0 -114
  59. package/registry/training/bad/postcss-README.md +0 -28
  60. package/registry/training/bad/prebuild-install-CHANGELOG.md +0 -131
  61. package/registry/training/bad/proxy-addr-HISTORY.md +0 -161
  62. package/registry/training/bad/qs-CHANGELOG.md +0 -822
  63. package/registry/training/bad/rc-README.md +0 -227
  64. package/registry/training/bad/readable-stream-CONTRIBUTING.md +0 -38
  65. package/registry/training/bad/router-HISTORY.md +0 -228
  66. package/registry/training/bad/semver-README.md +0 -680
  67. package/registry/training/bad/send-README.md +0 -317
  68. package/registry/training/bad/serve-static-README.md +0 -253
  69. package/registry/training/bad/statuses-HISTORY.md +0 -87
  70. package/registry/training/bad/type-is-HISTORY.md +0 -292
  71. package/registry/training/bad/vary-HISTORY.md +0 -39
  72. package/registry/training/bad/vite-LICENSE.md +0 -2230
  73. package/registry/training/bad/which-CHANGELOG.md +0 -166
  74. package/registry/training/bad/zod-README.md +0 -191
  75. package/registry/training/good/skills-store-autopilot.md +0 -85
  76. package/registry/training/good/skills-store-bot-builder.md +0 -70
  77. package/registry/training/good/skills-store-chain.md +0 -136
  78. package/registry/training/good/skills-store-evolve.md +0 -100
  79. package/registry/training/good/skills-store-game.md +0 -27
  80. package/registry/training/good/skills-store-hunt.md +0 -102
  81. package/registry/training/good/skills-store-intel.md +0 -56
  82. package/registry/training/good/skills-store-memory-gc.md +0 -58
  83. package/registry/training/good/skills-store-pcsort.md +0 -207
  84. package/registry/training/good/skills-store-pickup.md +0 -60
  85. package/registry/training/good/skills-store-recon.md +0 -141
  86. package/registry/training/good/skills-store-remember.md +0 -64
  87. package/registry/training/good/skills-store-report.md +0 -117
  88. package/registry/training/good/skills-store-router.md +0 -225
  89. package/registry/training/good/skills-store-search.md +0 -168
  90. package/registry/training/good/skills-store-surface.md +0 -53
  91. package/registry/training/good/skills-store-token-scan.md +0 -141
  92. package/registry/training/good/skills-store-triage.md +0 -97
  93. package/registry/training/good/skills-store-unity.md +0 -733
  94. package/registry/training/good/skills-store-validate.md +0 -135
  95. package/registry/training/good/skills-store-web3-audit.md +0 -217
package/indexer.js CHANGED
@@ -11,16 +11,29 @@ import { buildAnnIndex } from './ann.js';
11
11
  import { progress, progressDone, success, info, spinner } from './cli.js';
12
12
  import chalk from 'chalk';
13
13
 
14
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB per file
15
+ const MAX_FILE_COUNT = 100000; // 100k files per reindex
16
+
17
+ function sanitizePath(filePath) {
18
+ return path.resolve(filePath);
19
+ }
20
+
14
21
  async function indexBatch(db, skills, { fast = false } = {}) {
15
22
  const upsertSkill = db.prepare(`
16
- INSERT INTO skills (id, name, description, path, source, content, hash)
17
- VALUES (@id, @name, @description, @path, @source, @content, @hash)
23
+ INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
24
+ VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
18
25
  ON CONFLICT(id) DO UPDATE SET
19
26
  name = excluded.name,
20
27
  description = excluded.description,
21
28
  path = excluded.path,
22
29
  content = excluded.content,
23
- hash = excluded.hash
30
+ hash = excluded.hash,
31
+ version = excluded.version,
32
+ author = excluded.author,
33
+ license = excluded.license,
34
+ updated_at = excluded.updated_at,
35
+ downloads = excluded.downloads,
36
+ verified = excluded.verified
24
37
  `);
25
38
  const deleteChunks = db.prepare('DELETE FROM chunks WHERE skill_id = ?');
26
39
  const deleteEdges = db.prepare('DELETE FROM edges WHERE from_skill = ?');
@@ -51,7 +64,7 @@ async function indexBatch(db, skills, { fast = false } = {}) {
51
64
  db.transaction(() => {
52
65
  for (const skill of skills) {
53
66
  const id = skillId(skill.source, skill.name);
54
- upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null });
67
+ upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null, version: skill.version || null, author: skill.author || null, license: skill.license || null, updated_at: skill.updated_at || null, downloads: skill.downloads ?? 0, verified: skill.verified ? 1 : 0 });
55
68
  upsertFts.run(id, skill.name, skill.description || '', skill.content || '');
56
69
  if (!fast) {
57
70
  deleteChunks.run(id);
@@ -93,19 +106,30 @@ export async function indexAll({ fast = false } = {}) {
93
106
  normDir: path.resolve(s.dir),
94
107
  })).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
95
108
 
109
+ console.error('DEBUG normalizedSources count:', normalizedSources.length);
110
+ console.error('DEBUG first dir:', normalizedSources[0]?.dir);
111
+
96
112
  const seenFiles = new Set();
97
113
  const allFiles = [];
98
114
  for (const { dir, source } of normalizedSources) {
99
115
  const files = globSync(`${dir}/**/*.md`);
116
+ console.error('DEBUG source:', source, 'dir:', dir, 'count:', files.length);
100
117
  for (const f of files) {
101
- const norm = path.resolve(f);
118
+ const norm = sanitizePath(f);
102
119
  if (!seenFiles.has(norm)) {
103
120
  seenFiles.add(norm);
104
- allFiles.push({ file: f, source });
121
+ allFiles.push({ file: norm, source });
122
+ } else {
123
+ console.error('DEBUG duplicate:', norm);
105
124
  }
106
125
  }
126
+ if (allFiles.length > MAX_FILE_COUNT) {
127
+ info(chalk.yellow(`Reached max file count (${MAX_FILE_COUNT}) — truncating`));
128
+ break;
129
+ }
107
130
  }
108
131
  const total = allFiles.length;
132
+ console.error('DEBUG total files found:', total);
109
133
  info(`Found ${chalk.white.bold(total)} files`);
110
134
 
111
135
  // reconcile: remove skills whose files no longer exist OR whose name changed
@@ -150,9 +174,13 @@ export async function indexAll({ fast = false } = {}) {
150
174
 
151
175
  for (const { file, source } of allFiles) {
152
176
  try {
153
- // 1. Read file once
177
+ // 1. Read file once (with size gate)
154
178
  let raw;
155
- try { raw = fs.readFileSync(file, 'utf8'); } catch { skipped++; count++; continue; }
179
+ try {
180
+ const stat = fs.statSync(file);
181
+ if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
182
+ raw = fs.readFileSync(file, 'utf8');
183
+ } catch { skipped++; count++; continue; }
156
184
 
157
185
  // 2. Hash first — cheapest check
158
186
  const hash = createHash('md5').update(raw).digest('hex');
@@ -222,10 +250,13 @@ export async function indexAll({ fast = false } = {}) {
222
250
  }
223
251
 
224
252
  export async function indexFile(filePath, source) {
253
+ const safe = sanitizePath(filePath);
254
+ const stat = fs.statSync(safe);
255
+ if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large: ${filePath}`);
225
256
  const db = getDb();
226
- const raw = fs.readFileSync(filePath, 'utf8');
257
+ const raw = fs.readFileSync(safe, 'utf8');
227
258
  const hash = createHash('md5').update(raw).digest('hex');
228
- const skill = parseSkillFile(filePath, source, { raw });
259
+ const skill = parseSkillFile(safe, source, { raw });
229
260
  await indexBatch(db, [{ ...skill, hash }]);
230
261
  }
231
262
 
@@ -256,7 +287,10 @@ export async function indexSource(dir, sourceName) {
256
287
 
257
288
  for (const file of files) {
258
289
  try {
259
- const raw = fs.readFileSync(file, 'utf8');
290
+ const norm = sanitizePath(file);
291
+ const stat = fs.statSync(norm);
292
+ if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
293
+ const raw = fs.readFileSync(norm, 'utf8');
260
294
  const hash = createHash('md5').update(raw).digest('hex');
261
295
  const dbRow = dbByPath.get(file);
262
296
  if (dbRow?.hash === hash) { skipped++; count++; continue; }
package/marketplace.js CHANGED
@@ -42,6 +42,32 @@ 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 (errors.length > 0) {
65
+ console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
66
+ return { ok: false };
67
+ }
68
+ return { ok: true };
69
+ }
70
+
45
71
  // Deterministic short code from an id. Same id always yields the same code,
46
72
  // so codes auto-generate — no need to assign them by hand.
47
73
  export function codeFor(id) {
@@ -137,6 +163,8 @@ export async function browseMarketplace(topK = 20) {
137
163
  if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
138
164
  return registry.skills
139
165
  .map(s => ({ ...s, code: s.code || codeFor(s.id) })) // auto-fill code
166
+ .filter(s => validateRegistryEntry(s).ok)
167
+ .filter(s => s.raw_url)
140
168
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
141
169
  .slice(0, topK);
142
170
  } catch (e) {
@@ -154,6 +182,10 @@ export async function installSkillFromUrl(url) {
154
182
  // derive filename from URL
155
183
  const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
156
184
  const dest = path.join(SKILLS_DIR, urlName.endsWith('.md') ? urlName : urlName + '.md');
185
+ const resolvedDest = path.resolve(dest);
186
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
187
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
188
+ }
157
189
  const v = writeSkillAtomic(dest, content);
158
190
  if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
159
191
  return { success: true, path: dest, url: rawUrl };
@@ -172,8 +204,9 @@ export async function installSkill(query) {
172
204
  const text = await fetchText(REGISTRY_URL);
173
205
  const registry = JSON.parse(text);
174
206
  const q = String(query).trim().toLowerCase();
207
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
175
208
  // match by code (stored OR auto-generated), id, or name
176
- const skill = registry.skills?.find(s =>
209
+ const skill = validSkills.find(s =>
177
210
  (s.code || codeFor(s.id)).toLowerCase() === q ||
178
211
  s.id?.toLowerCase() === q ||
179
212
  s.name?.toLowerCase() === q
@@ -191,6 +224,10 @@ export async function installSkill(query) {
191
224
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
192
225
  ensureMarketplaceSource();
193
226
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
227
+ const resolvedDest = path.resolve(dest);
228
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
229
+ return { error: 'Path traversal blocked: destination outside marketplace directory' };
230
+ }
194
231
 
195
232
  const content = await fetchText(skill.raw_url);
196
233
  const v = writeSkillAtomic(dest, content);
@@ -222,7 +259,7 @@ export async function browseBundles(topK = 20) {
222
259
  try {
223
260
  const text = await fetchText(REGISTRY_URL);
224
261
  const registry = JSON.parse(text);
225
- const bundles = registry.bundles || [];
262
+ const bundles = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok);
226
263
  const cache = readSkillCountCache();
227
264
  const now = Date.now();
228
265
  let changed = false;
@@ -273,7 +310,8 @@ export async function installBundle(bundleId) {
273
310
  const text = await fetchText(REGISTRY_URL);
274
311
  const registry = JSON.parse(text);
275
312
  const q = String(bundleId).trim().toLowerCase();
276
- const bundle = (registry.bundles || []).find(b =>
313
+ const validSkills = (registry.skills || []).filter(s => validateRegistryEntry(s).ok);
314
+ const bundle = (registry.bundles || []).filter(b => validateRegistryEntry(b).ok).find(b =>
277
315
  (b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q || b.name?.toLowerCase() === q
278
316
  );
279
317
  if (!bundle) return { error: `No bundle matching "${bundleId}"` };
@@ -290,12 +328,16 @@ export async function installBundle(bundleId) {
290
328
 
291
329
  const delay = (ms) => new Promise(r => setTimeout(r, ms));
292
330
  for (const skillId of bundle.skills || []) {
293
- const skill = registry.skills?.find(s => s.id === skillId);
331
+ const skill = validSkills.find(s => s.id === skillId);
294
332
  if (!skill?.raw_url) { failed.push(skillId); continue; }
295
333
  try {
296
334
  if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
297
335
  const content = await fetchText(skill.raw_url);
298
336
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
337
+ const resolvedDest = path.resolve(dest);
338
+ if (!resolvedDest.startsWith(path.resolve(SKILLS_DIR))) {
339
+ failed.push(skillId); continue;
340
+ }
299
341
  const v = writeSkillAtomic(dest, content);
300
342
  if (!v.ok) { failed.push(skillId); continue; }
301
343
  installed.push(skillId);
@@ -478,7 +520,7 @@ export function pruneInvalidRepos() {
478
520
  continue;
479
521
  }
480
522
 
481
- const mdFiles = globSync(`${src.dir}/**/*.md`);
523
+ const mdFiles = globSync(`${src.dir}/**/*.md`).map(fp => path.resolve(fp));
482
524
  if (mdFiles.length === 0) {
483
525
  removed.push({ repo: repoName, reason: 'no .md files' });
484
526
  config.sources = config.sources.filter(s => s !== src);
package/package.json CHANGED
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.4.7",
3
+ "version": "2.4.8",
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": "^6.2.1"
53
59
  }
54
60
  }
package/search.js CHANGED
@@ -2,6 +2,26 @@ 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
15
+ 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
+ }
23
+ }
24
+
5
25
  function applyRatingBoost(db, id, score) {
6
26
  const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
7
27
  if (r && (r.success + r.fail) > 3) {
@@ -11,13 +31,13 @@ function applyRatingBoost(db, id, score) {
11
31
  return score;
12
32
  }
13
33
 
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);
34
+ function normalizeBM25(raw) {
35
+ return Math.max(0, 1 + raw / 10);
36
+ }
20
37
 
38
+ async function runEmbeddingSearch(db, queryVec, topK) {
39
+ // Try ANN index first (fast)
40
+ const annResults = await annSearch(queryVec, topK);
21
41
  if (annResults && annResults.length > 0) {
22
42
  const bestBySkill = new Map();
23
43
  for (const r of annResults) {
@@ -25,11 +45,8 @@ export async function search(query, topK = 5) {
25
45
  if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
26
46
  }
27
47
  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);
48
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
49
+ .sort((a, b) => b.score - a.score);
33
50
  }
34
51
 
35
52
  // Fallback: brute force cosine (used before first reindex)
@@ -42,14 +59,14 @@ export async function search(query, topK = 5) {
42
59
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
43
60
  }
44
61
  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);
62
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
63
+ .sort((a, b) => b.score - a.score);
50
64
  }
51
65
 
52
- // Fast-mode fallback: FTS5 keyword search (no embeddings)
66
+ return [];
67
+ }
68
+
69
+ function runBM25Search(db, query, topK) {
53
70
  try {
54
71
  const terms = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(Boolean).join(' OR ');
55
72
  const rows = db.prepare(
@@ -57,14 +74,61 @@ export async function search(query, topK = 5) {
57
74
  JOIN skills s ON skills_fts.id = s.id
58
75
  WHERE skills_fts MATCH ? ORDER BY score LIMIT ?`
59
76
  ).all(terms, topK);
60
- return rows.map(r => skillWithSnippet(db, r.id, Math.max(0, 1 + r.score / 10))).filter(Boolean);
77
+ return rows.map(r => ({ id: r.id, score: normalizeBM25(r.score) }));
61
78
  } catch {
62
79
  return [];
63
80
  }
64
81
  }
65
82
 
83
+ export async function search(query, topK = 5) {
84
+ const db = getDb();
85
+ adaptWeights(query);
86
+ const queryVec = await embed(query);
87
+
88
+ const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
89
+ const bm25Results = runBM25Search(db, query, topK * 4);
90
+
91
+ // Fallback: pure BM25 if embeddings unavailable
92
+ if (!embedResults.length) {
93
+ return bm25Results.slice(0, topK)
94
+ .map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score)))
95
+ .filter(Boolean);
96
+ }
97
+
98
+ // Fallback: pure embedding if BM25 returns nothing
99
+ if (!bm25Results.length) {
100
+ return embedResults.slice(0, topK)
101
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
102
+ .filter(Boolean);
103
+ }
104
+
105
+ // Hybrid: combine normalized scores from both signals
106
+ const combined = new Map();
107
+ for (const { id, score } of embedResults) {
108
+ combined.set(id, { embedScore: score, bm25Score: 0 });
109
+ }
110
+ for (const { id, score } of bm25Results) {
111
+ const entry = combined.get(id);
112
+ if (entry) {
113
+ entry.bm25Score = score;
114
+ } else {
115
+ combined.set(id, { embedScore: 0, bm25Score: score });
116
+ }
117
+ }
118
+
119
+ return [...combined.entries()]
120
+ .map(([id, s]) => ({
121
+ id,
122
+ score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
123
+ }))
124
+ .sort((a, b) => b.score - a.score)
125
+ .slice(0, topK)
126
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
127
+ .filter(Boolean);
128
+ }
129
+
66
130
  function skillWithSnippet(db, id, score) {
67
- const skill = db.prepare('SELECT id, name, description, path, source, content FROM skills WHERE id = ?').get(id);
131
+ 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
132
  if (!skill) return null;
69
133
  const { content, ...rest } = skill;
70
134
  const snippet = content?.replace(/^---[\s\S]*?---\n?/, '').trim().slice(0, 400) || '';
@@ -131,5 +195,5 @@ export function getImpact(nameOrId) {
131
195
 
132
196
  export function listAll() {
133
197
  const db = getDb();
134
- return db.prepare('SELECT id, name, description, source FROM skills ORDER BY source, name').all();
198
+ return db.prepare('SELECT id, name, description, source, version, author, license, updated_at, downloads, verified FROM skills ORDER BY source, name').all();
135
199
  }
@@ -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,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
+ }