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/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
- function httpsGet(url) {
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,12 +84,24 @@ 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
- const content = await httpsGet(file.download_url);
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);
104
+ fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
48
105
  fs.writeFileSync(tmpPath, content);
49
106
  const result = validateSkill(tmpPath);
50
107
  if (!result.ok) {
@@ -70,17 +127,28 @@ export async function validateRepoSkills(ownerRepo) {
70
127
 
71
128
  let mdFiles;
72
129
  if (detected) {
73
- // Has a skills subdirectory — list contents
130
+ // Has a skills subdirectory — use git tree API for recursive listing
74
131
  const subdir = detected.subdir;
75
- let entries;
76
132
  try {
77
- const json = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${subdir}`);
78
- entries = JSON.parse(json);
133
+ const treeJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`);
134
+ const tree = JSON.parse(treeJson);
135
+ const prefix = subdir + '/';
136
+ const mdTreeEntries = (tree.tree || []).filter(f =>
137
+ f.type === 'blob' && f.path.startsWith(prefix) && f.path.endsWith('.md')
138
+ );
139
+ let branch = 'main';
140
+ try {
141
+ const repoJson = await httpsGet(`https://api.github.com/repos/${ownerRepo}`);
142
+ branch = JSON.parse(repoJson).default_branch || 'main';
143
+ } catch {}
144
+ mdFiles = mdTreeEntries.map(f => ({
145
+ name: f.path.replace(prefix, ''),
146
+ download_url: `https://raw.githubusercontent.com/${ownerRepo}/${branch}/${f.path}`
147
+ }));
79
148
  } catch (e) {
80
149
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
81
150
  return { ok: false, errors: [`Failed to list ${subdir}/ contents: ${e.message}`], warnings: [] };
82
151
  }
83
- mdFiles = entries.filter(e => e.type === 'file' && e.name.endsWith('.md'));
84
152
  } else {
85
153
  // No skills subdir — fall back to root-level .md files
86
154
  try {
@@ -107,7 +175,7 @@ export async function validateRepoSkills(ownerRepo) {
107
175
  let warnings = [];
108
176
 
109
177
  for (const file of mdToValidate) {
110
- const r = await validateMdFile(file, tmpDir);
178
+ const r = await validateMdFile(file, tmpDir, ownerRepo);
111
179
  errors.push(...r.errors);
112
180
  warnings.push(...r.warnings);
113
181
  }
@@ -131,6 +199,20 @@ async function detectSkillsDirFromAPI(ownerRepo) {
131
199
  if (dirMap.has(d)) return { subdir: dirMap.get(d), label: d };
132
200
  }
133
201
 
202
+ // 1.5 Nested skills dirs (e.g. .claude/skills, .claude-plugin/commands)
203
+ for (const prefix of ['.claude', '.claude-plugin']) {
204
+ if (dirMap.has(prefix)) {
205
+ for (const d of SKILL_DIRS) {
206
+ const nested = `${prefix}/${d}`;
207
+ try {
208
+ const sub = await httpsGet(`https://api.github.com/repos/${ownerRepo}/contents/${nested}`);
209
+ const subEntries = JSON.parse(sub);
210
+ if (subEntries.length > 0) return { subdir: nested, label: nested };
211
+ } catch {}
212
+ }
213
+ }
214
+ }
215
+
134
216
  // 2. Any subdir with 2+ .md files — pick the one with most .md files
135
217
  const subdirCandidates = entries.filter(e => e.type === 'dir' && !SKIP_DIRS_API.has(e.name.toLowerCase()));
136
218
  let best = null, bestCount = 0;
@@ -152,6 +234,8 @@ const SKIP_DIRS_API = new Set([
152
234
  '.github', 'docs', 'doc', 'documentation', 'examples', 'example',
153
235
  'tests', 'test', 'assets', 'images', 'img', 'media', 'static',
154
236
  'node_modules', 'vendor', 'dist', 'build', '.git',
237
+ 'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
238
+ 'cheatsheets', 'resources',
155
239
  ]);
156
240
 
157
241
  function git(args, cwd, stdio = 'inherit') {
@@ -177,16 +261,23 @@ function sparseClone(url, dest, subdir) {
177
261
  // Try HEAD, then main, then master
178
262
  for (const branch of ['HEAD', 'main', 'master']) {
179
263
  const r = git(['checkout', branch === 'HEAD' ? 'FETCH_HEAD' : branch], dest, 'pipe');
180
- if (r.status === 0) return true;
264
+ if (r.status === 0) return finalizeCheckout(dest, true);
181
265
  }
182
266
  return false;
183
267
  }
184
268
 
269
+ // Shared skip patterns — module scope so both cleanup functions can access them
270
+ const SKIP_RE = /^(readme|changelog|license|contributing|code.of.conduct|security|authors|credits|install|installation|usage|promotion|faq|glossary|index|overview|summary|roadmap|todo|notes|template|example|sample|demo|guide|tutorial|walkthrough|architecture|design|spec|requirements|privacy|terms|disclaimer|notice|copying|warranty|funding)/i;
271
+ const SKIP_DIRS_LOCAL = new Set([
272
+ '.github', 'docs', 'doc', 'assets', 'images', 'img', 'screenshots',
273
+ 'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor',
274
+ 'dist', 'build', 'tests', 'test',
275
+ 'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
276
+ 'cheatsheets', 'resources',
277
+ ]);
278
+
185
279
  // After full-clone root: remove files that are not skills and dirs we don't need
186
280
  function cleanupRepoRoot(repoRoot) {
187
- const SKIP_RE = /^(readme|changelog|license|contributing|code.of.conduct|security|authors|credits|install|installation|usage|promotion|faq|glossary|index|overview|summary|roadmap|todo|notes|template|example|sample|demo|guide|tutorial|walkthrough|architecture|design|spec|requirements|privacy|terms|disclaimer|notice|copying|warranty|funding)/i;
188
- const SKIP_DIRS_LOCAL = new Set(['.github', 'docs', 'doc', 'assets', 'images', 'img', 'screenshots', 'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor', 'dist', 'build', 'tests', 'test']);
189
-
190
281
  let removed = 0;
191
282
  const entries = fs.readdirSync(repoRoot, { withFileTypes: true });
192
283
  for (const entry of entries) {
@@ -223,9 +314,14 @@ function cleanupRepoDir(dirPath, SKIP_RE) {
223
314
  for (const entry of entries) {
224
315
  const fullPath = path.join(dirPath, entry.name);
225
316
  if (entry.isDirectory()) {
226
- removed += cleanupRepoDir(fullPath, SKIP_RE);
227
- // Remove empty dirs after cleanup
228
- try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
317
+ // Remove entire skip dirs (e.g. references/) nested inside skills dirs
318
+ if (SKIP_DIRS_LOCAL.has(entry.name.toLowerCase())) {
319
+ fs.rmSync(fullPath, { recursive: true, force: true });
320
+ removed++;
321
+ } else {
322
+ removed += cleanupRepoDir(fullPath, SKIP_RE);
323
+ try { if (fs.readdirSync(fullPath).length === 0) fs.rmdirSync(fullPath); } catch {}
324
+ }
229
325
  } else if (entry.isFile() && entry.name.endsWith('.md')) {
230
326
  const base = entry.name.replace(/\.md$/i, '').toLowerCase();
231
327
  if (SKIP_RE.test(base)) {
@@ -252,28 +348,42 @@ function removeEmptyDirs(dirPath) {
252
348
  }
253
349
  }
254
350
 
255
- // Update sparse repo fetch + reset
351
+ // After fetch/checkout: force materialization of all sparse-matched files.
352
+ // partial clone (blob:none) + checkout often skips blob download on Windows.
353
+ function forceMaterialize(dest) {
354
+ git(['checkout', 'HEAD', '--', '.'], dest, 'pipe');
355
+ }
356
+
357
+ // Update sparse repo — fetch + checkout
256
358
  function sparseUpdate(dest, subdir) {
257
359
  const fetch = git(['fetch', '--depth=1', 'origin'], dest);
258
360
  if (fetch.status !== 0) return false;
259
361
 
260
- // Ensure sparse-checkout still set correctly
261
362
  git(['sparse-checkout', 'set', '--no-cone', `${subdir}/*.md`, `${subdir}/**/*.md`], dest, 'pipe');
262
363
 
263
- for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
264
- const r = git(['reset', '--hard', ref], dest, 'pipe');
265
- if (r.status === 0) return true;
364
+ for (const ref of ['origin/main', 'origin/master']) {
365
+ const r = git(['checkout', ref], dest, 'pipe');
366
+ if (r.status !== 0) continue;
367
+ forceMaterialize(dest);
368
+ return true;
266
369
  }
267
370
  return false;
268
371
  }
269
372
 
373
+ // After checkout, force materialization of sparse-matched files.
374
+ function finalizeCheckout(dest, success) {
375
+ if (success) forceMaterialize(dest);
376
+ return success;
377
+ }
378
+
270
379
  // Fallback: clone root but only checkout .md files
271
380
  function fullClone(url, dest) {
272
381
  if (fs.existsSync(dest)) {
273
382
  const fetch = git(['fetch', '--depth=1', '--filter=blob:none', 'origin'], dest);
274
383
  if (fetch.status !== 0) return false;
275
384
  for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
276
- if (git(['reset', '--hard', ref], dest, 'pipe').status === 0) return true;
385
+ const ok = git(['reset', '--hard', ref], dest, 'pipe').status === 0;
386
+ if (ok) return finalizeCheckout(dest, true);
277
387
  }
278
388
  return false;
279
389
  }
@@ -287,13 +397,14 @@ function fullClone(url, dest) {
287
397
  if (fetch.status !== 0) return false;
288
398
  for (const branch of ['FETCH_HEAD', 'main', 'master']) {
289
399
  const r = git(['checkout', branch], dest, 'pipe');
290
- if (r.status === 0) return true;
400
+ if (r.status === 0) return finalizeCheckout(dest, true);
291
401
  }
292
402
  return false;
293
403
  }
294
404
 
295
- // After clone: detect actual skills dir on disk
405
+ // After clone: detect actual skills dir on disk (flat + nested)
296
406
  function detectSkillsDirLocal(repoRoot) {
407
+ // 1. Flat dirs matching SKILL_DIRS (e.g. skills/, commands/)
297
408
  for (const dir of SKILL_DIRS) {
298
409
  const candidate = path.join(repoRoot, dir);
299
410
  if (fs.existsSync(candidate)) {
@@ -301,6 +412,16 @@ function detectSkillsDirLocal(repoRoot) {
301
412
  if (files.length >= 1) return { dir: candidate, label: dir, sparse: true };
302
413
  }
303
414
  }
415
+ // 2. Nested dirs: .claude/skills, .claude-plugin/commands, etc.
416
+ for (const prefix of ['.claude', '.claude-plugin']) {
417
+ for (const dir of SKILL_DIRS) {
418
+ const candidate = path.join(repoRoot, prefix, dir);
419
+ if (fs.existsSync(candidate)) {
420
+ const files = globSync(`${candidate}/**/*.md`);
421
+ if (files.length >= 1) return { dir: candidate, label: `${prefix}/${dir}`, sparse: true };
422
+ }
423
+ }
424
+ }
304
425
  return { dir: repoRoot, label: '(root)', sparse: false };
305
426
  }
306
427
 
@@ -356,7 +477,17 @@ export async function importFromGitHub(repoUrl) {
356
477
  const sparseList = isSparse
357
478
  ? spawnSync('git', ['sparse-checkout', 'list'], { cwd: dest, encoding: 'utf8' }).stdout.trim()
358
479
  : '';
359
- skillsSubdir = sparseList.split('\n').find(l => SKILL_DIRS.includes(l.trim())) || null;
480
+ skillsSubdir = (() => {
481
+ const lines = sparseList.split('\n').map(l => l.trim()).filter(Boolean);
482
+ if (lines.length === 0) return null;
483
+ const exact = lines.find(l => SKILL_DIRS.includes(l));
484
+ if (exact) return exact;
485
+ for (const line of lines) {
486
+ const m = line.match(/^(.+)\/(?:\*\*\/)?\*\.md$/);
487
+ if (m && !m[1].includes('*')) return m[1];
488
+ }
489
+ return null;
490
+ })();
360
491
 
361
492
  cloneOk = skillsSubdir
362
493
  ? sparseUpdate(dest, skillsSubdir)
@@ -381,9 +512,18 @@ export async function importFromGitHub(repoUrl) {
381
512
  removeEmptyDirs(dest);
382
513
  }
383
514
 
384
- const { dir: skillsDir, label } = detectSkillsDirLocal(dest);
515
+ const { dir: localDir, label: localLabel } = detectSkillsDirLocal(dest);
516
+ // Prefer the known skillsSubdir (from API detection or sparse patterns) as the
517
+ // canonical skills directory — it's more accurate than local heuristics for
518
+ // repos with non-standard dir names (e.g. "specialized", "cli", or nested paths)
519
+ const skillsDir = skillsSubdir ? path.join(dest, skillsSubdir) : localDir;
520
+ const label = skillsSubdir || localLabel;
385
521
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
386
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
+
387
527
  if (skillsSubdir) {
388
528
  console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
389
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, 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';
@@ -11,16 +12,29 @@ import { buildAnnIndex } from './ann.js';
11
12
  import { progress, progressDone, success, info, spinner } from './cli.js';
12
13
  import chalk from 'chalk';
13
14
 
15
+ const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB per file
16
+ const MAX_FILE_COUNT = 100000; // 100k files per reindex
17
+
18
+ function sanitizePath(filePath) {
19
+ return path.resolve(filePath);
20
+ }
21
+
14
22
  async function indexBatch(db, skills, { fast = false } = {}) {
15
23
  const upsertSkill = db.prepare(`
16
- INSERT INTO skills (id, name, description, path, source, content, hash)
17
- VALUES (@id, @name, @description, @path, @source, @content, @hash)
24
+ INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
25
+ VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
18
26
  ON CONFLICT(id) DO UPDATE SET
19
27
  name = excluded.name,
20
28
  description = excluded.description,
21
29
  path = excluded.path,
22
30
  content = excluded.content,
23
- hash = excluded.hash
31
+ hash = excluded.hash,
32
+ version = excluded.version,
33
+ author = excluded.author,
34
+ license = excluded.license,
35
+ updated_at = excluded.updated_at,
36
+ downloads = excluded.downloads,
37
+ verified = excluded.verified
24
38
  `);
25
39
  const deleteChunks = db.prepare('DELETE FROM chunks WHERE skill_id = ?');
26
40
  const deleteEdges = db.prepare('DELETE FROM edges WHERE from_skill = ?');
@@ -51,7 +65,7 @@ async function indexBatch(db, skills, { fast = false } = {}) {
51
65
  db.transaction(() => {
52
66
  for (const skill of skills) {
53
67
  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 });
68
+ 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
69
  upsertFts.run(id, skill.name, skill.description || '', skill.content || '');
56
70
  if (!fast) {
57
71
  deleteChunks.run(id);
@@ -93,19 +107,30 @@ export async function indexAll({ fast = false } = {}) {
93
107
  normDir: path.resolve(s.dir),
94
108
  })).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
95
109
 
110
+ console.error('DEBUG normalizedSources count:', normalizedSources.length);
111
+ console.error('DEBUG first dir:', normalizedSources[0]?.dir);
112
+
96
113
  const seenFiles = new Set();
97
114
  const allFiles = [];
98
115
  for (const { dir, source } of normalizedSources) {
99
116
  const files = globSync(`${dir}/**/*.md`);
117
+ console.error('DEBUG source:', source, 'dir:', dir, 'count:', files.length);
100
118
  for (const f of files) {
101
- const norm = path.resolve(f);
119
+ const norm = sanitizePath(f);
102
120
  if (!seenFiles.has(norm)) {
103
121
  seenFiles.add(norm);
104
- allFiles.push({ file: f, source });
122
+ allFiles.push({ file: norm, source });
123
+ } else {
124
+ console.error('DEBUG duplicate:', norm);
105
125
  }
106
126
  }
127
+ if (allFiles.length > MAX_FILE_COUNT) {
128
+ info(chalk.yellow(`Reached max file count (${MAX_FILE_COUNT}) — truncating`));
129
+ break;
130
+ }
107
131
  }
108
132
  const total = allFiles.length;
133
+ console.error('DEBUG total files found:', total);
109
134
  info(`Found ${chalk.white.bold(total)} files`);
110
135
 
111
136
  // reconcile: remove skills whose files no longer exist OR whose name changed
@@ -150,9 +175,13 @@ export async function indexAll({ fast = false } = {}) {
150
175
 
151
176
  for (const { file, source } of allFiles) {
152
177
  try {
153
- // 1. Read file once
178
+ // 1. Read file once (with size gate)
154
179
  let raw;
155
- try { raw = fs.readFileSync(file, 'utf8'); } catch { skipped++; count++; continue; }
180
+ try {
181
+ const stat = fs.statSync(file);
182
+ if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
183
+ raw = fs.readFileSync(file, 'utf8');
184
+ } catch { skipped++; count++; continue; }
156
185
 
157
186
  // 2. Hash first — cheapest check
158
187
  const hash = createHash('md5').update(raw).digest('hex');
@@ -182,6 +211,7 @@ export async function indexAll({ fast = false } = {}) {
182
211
  batch = [];
183
212
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
184
213
  progress(count, total, { skipped, eta, errors });
214
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
185
215
  }
186
216
  } catch (e) {
187
217
  errors++;
@@ -222,10 +252,13 @@ export async function indexAll({ fast = false } = {}) {
222
252
  }
223
253
 
224
254
  export async function indexFile(filePath, source) {
255
+ const safe = sanitizePath(filePath);
256
+ const stat = fs.statSync(safe);
257
+ if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large: ${filePath}`);
225
258
  const db = getDb();
226
- const raw = fs.readFileSync(filePath, 'utf8');
259
+ const raw = fs.readFileSync(safe, 'utf8');
227
260
  const hash = createHash('md5').update(raw).digest('hex');
228
- const skill = parseSkillFile(filePath, source, { raw });
261
+ const skill = parseSkillFile(safe, source, { raw });
229
262
  await indexBatch(db, [{ ...skill, hash }]);
230
263
  }
231
264
 
@@ -256,7 +289,10 @@ export async function indexSource(dir, sourceName) {
256
289
 
257
290
  for (const file of files) {
258
291
  try {
259
- const raw = fs.readFileSync(file, 'utf8');
292
+ const norm = sanitizePath(file);
293
+ const stat = fs.statSync(norm);
294
+ if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
295
+ const raw = fs.readFileSync(norm, 'utf8');
260
296
  const hash = createHash('md5').update(raw).digest('hex');
261
297
  const dbRow = dbByPath.get(file);
262
298
  if (dbRow?.hash === hash) { skipped++; count++; continue; }
@@ -267,6 +303,7 @@ export async function indexSource(dir, sourceName) {
267
303
  await indexBatch(db, batch);
268
304
  count += batch.length; batch = [];
269
305
  progress(count, total, { skipped, errors });
306
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
270
307
  }
271
308
  } catch (e) { errors++; count++; }
272
309
  }