promptgraph-mcp 2.4.6 → 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.
package/index.js CHANGED
@@ -16,7 +16,7 @@ const args = process.argv.slice(2);
16
16
  const rawBin = process.argv[1]?.split(/[\\/]/).pop()?.replace(/\.js$/, '');
17
17
  const bin = (rawBin && rawBin !== 'index') ? rawBin : 'pg';
18
18
 
19
- const KNOWN_COMMANDS = new Set(['init', 'reindex', 'update', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h', 'bundle', 'status']);
19
+ const KNOWN_COMMANDS = new Set(['init', 'reindex', 'update', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h', 'bundle', 'status', 'train']);
20
20
 
21
21
  function showHelp() {
22
22
  console.log(
@@ -319,24 +319,41 @@ if (args[0] === 'validate') {
319
319
 
320
320
  const raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
321
321
 
322
- // Show indexing score breakdown
323
322
  if (raw) {
324
- const { skillScore: _score } = await import('./parser.js').catch(() => ({}));
325
- const willIndex = isSkillFile(file, raw);
323
+ const { filterWithClassifier, isSkillFile: _isSkill } = await import('./parser.js');
324
+ const { hardFilter } = await import('./src/filter/hard-filter.js');
325
+ const { loadModel } = await import('./src/filter/train.js');
326
+ const { embed } = await import('./embedder.js');
327
+ const { classify } = await import('./src/filter/classifier.js');
328
+
329
+ const hfResult = hardFilter(file, raw);
330
+ const willIndex = _isSkill(file, raw);
326
331
  const scoreLabel = willIndex ? chalk.green('✓ will be indexed') : chalk.red('✗ will be skipped by indexer');
327
332
  console.log(chalk.bold('\n Indexing check: ') + scoreLabel);
328
333
 
329
- // Show which signals were detected
330
- const lines = raw.split('\n').filter(l => l.trim());
331
334
  const signals = [];
332
- try { const { data } = (await import('gray-matter')).default(raw); if (data.name) signals.push(chalk.green('+4 frontmatter name:')); } catch {}
333
- if (/^#{1,3}\s+(steps?|usage|instructions?|how\s+to|when\s+to\s+use|workflow)/im.test(raw)) signals.push(chalk.green('+2 instructional headers (## Steps / ## Usage)'));
334
- if (lines.filter(l => /^#{1,3}\s/.test(l)).some(h => /\b(run|use|fix|debug|check|create|deploy|scan|audit)\b/i.test(h))) signals.push(chalk.green('+2 imperative verbs in headers'));
335
- if (raw.includes('```')) signals.push(chalk.green('+1 code block'));
336
- if (lines.some(l => /^\d+\.\s/.test(l))) signals.push(chalk.green('+1 numbered list'));
337
- if (lines.some(l => /^[-*+]\s/.test(l))) signals.push(chalk.green('+1 bullet list'));
338
- const firstH = lines.find(l => /^#{1,3}\s/.test(l))?.replace(/^#+\s*/, '') || '';
339
- if (/^(overview|introduction|about|background|welcome)/i.test(firstH)) signals.push(chalk.red('-3 first header looks like docs ("' + firstH + '")'));
335
+ if (!hfResult.pass) {
336
+ signals.push(chalk.red(`✗ hard filter: ${hfResult.reason}`));
337
+ } else {
338
+ signals.push(chalk.green(' hard filter passed'));
339
+ }
340
+
341
+ const centroids = loadModel();
342
+ if (centroids) {
343
+ try {
344
+ const vec = await embed(raw);
345
+ const decision = classify(vec, centroids, raw, file);
346
+ const pct = (decision.score * 100).toFixed(0);
347
+ if (decision.label === 'skill') signals.push(chalk.green(`✓ classifier: skill (${pct}%)`));
348
+ else if (decision.label === 'unsure') signals.push(chalk.yellow(`? classifier: unsure (${pct}%)`));
349
+ else signals.push(chalk.red(`✗ classifier: reject (${pct}%)`));
350
+ } catch {
351
+ signals.push(chalk.gray(' classifier: embed failed (skip)'));
352
+ }
353
+ } else {
354
+ signals.push(chalk.gray(' classifier: no model (run `pg train`)'));
355
+ }
356
+
340
357
  if (signals.length) {
341
358
  signals.forEach(s => console.log(' ' + s));
342
359
  }
@@ -355,6 +372,22 @@ if (args[0] === 'validate') {
355
372
  }
356
373
  }
357
374
 
375
+ if (args[0] === 'train') {
376
+ const { train: trainModel } = await import('./src/filter/train.js');
377
+ const spin = (await import('./cli.js')).spinner('Training classifier...');
378
+ spin.start();
379
+ try {
380
+ const model = await trainModel();
381
+ spin.stop();
382
+ success(`Classifier trained (${model.counts.good} good, ${model.counts.bad} bad examples)`);
383
+ } catch (e) {
384
+ spin.stop();
385
+ error(`Training failed: ${e.message}`);
386
+ process.exit(1);
387
+ }
388
+ process.exit(0);
389
+ }
390
+
358
391
  if (args[0] === 'search') {
359
392
  const query = args.slice(1).join(' ');
360
393
  if (!query) { error('Usage: ' + bin + ' search <query>'); process.exit(1); }
package/indexer.js CHANGED
@@ -2,8 +2,8 @@ import { globSync } from 'glob';
2
2
  import { createHash } from 'crypto';
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
- import { parseSkillFile, isSkillFile } from './parser.js';
6
- import { embedBatch, BATCH_SIZE } from './embedder.js';
5
+ import { parseSkillFile, isSkillFile, filterWithClassifier } from './parser.js';
6
+ import { embedBatch, cosineSimilarity, BATCH_SIZE } from './embedder.js';
7
7
  import { getDb, skillId, vecToBlob } from './db.js';
8
8
  import { loadConfig } from './config.js';
9
9
  import { chunkText } from './chunker.js';
@@ -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
@@ -138,6 +162,7 @@ export async function indexAll({ fast = false } = {}) {
138
162
  let count = 0;
139
163
  let errors = 0;
140
164
  let skipped = 0;
165
+ let classifierRemoved = 0;
141
166
  let batch = [];
142
167
  const start = Date.now();
143
168
 
@@ -149,9 +174,13 @@ export async function indexAll({ fast = false } = {}) {
149
174
 
150
175
  for (const { file, source } of allFiles) {
151
176
  try {
152
- // 1. Read file once
177
+ // 1. Read file once (with size gate)
153
178
  let raw;
154
- 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; }
155
184
 
156
185
  // 2. Hash first — cheapest check
157
186
  const hash = createHash('md5').update(raw).digest('hex');
@@ -174,8 +203,10 @@ export async function indexAll({ fast = false } = {}) {
174
203
  batch.push({ ...parsed, hash });
175
204
 
176
205
  if (batch.length >= BATCH_SIZE) {
177
- await indexBatch(db, batch, { fast });
178
- count += batch.length;
206
+ const filtered = await filterWithClassifier(batch);
207
+ classifierRemoved += batch.length - filtered.length;
208
+ await indexBatch(db, filtered, { fast });
209
+ count += filtered.length;
179
210
  batch = [];
180
211
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
181
212
  progress(count, total, { skipped, eta, errors });
@@ -196,8 +227,10 @@ export async function indexAll({ fast = false } = {}) {
196
227
  }
197
228
 
198
229
  if (batch.length > 0) {
199
- await indexBatch(db, batch, { fast });
200
- count += batch.length;
230
+ const filtered = await filterWithClassifier(batch);
231
+ classifierRemoved += batch.length - filtered.length;
232
+ await indexBatch(db, filtered, { fast });
233
+ count += filtered.length;
201
234
  }
202
235
 
203
236
  progress(total, total, { skipped, errors });
@@ -208,17 +241,22 @@ export async function indexAll({ fast = false } = {}) {
208
241
  await buildAnnIndex();
209
242
  spin.stop();
210
243
  }
211
- success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${errors} errors, ${skipped} skipped, ${removed} removed)`)}`);
244
+ const stats = [`${errors} errors`, `${skipped} skipped`, `${removed} removed`];
245
+ if (classifierRemoved > 0) stats.push(`${classifierRemoved} filtered`);
246
+ success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${stats.join(', ')})`)}`);
212
247
  if (fast) info(chalk.yellow('Fast mode: keyword search only. Run `pg reindex` for semantic search.'));
213
248
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
214
249
  info(chalk.gray(`Time: ${elapsed}s`));
215
250
  }
216
251
 
217
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}`);
218
256
  const db = getDb();
219
- const raw = fs.readFileSync(filePath, 'utf8');
257
+ const raw = fs.readFileSync(safe, 'utf8');
220
258
  const hash = createHash('md5').update(raw).digest('hex');
221
- const skill = parseSkillFile(filePath, source, { raw });
259
+ const skill = parseSkillFile(safe, source, { raw });
222
260
  await indexBatch(db, [{ ...skill, hash }]);
223
261
  }
224
262
 
@@ -249,7 +287,10 @@ export async function indexSource(dir, sourceName) {
249
287
 
250
288
  for (const file of files) {
251
289
  try {
252
- 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');
253
294
  const hash = createHash('md5').update(raw).digest('hex');
254
295
  const dbRow = dbByPath.get(file);
255
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.6",
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/parser.js CHANGED
@@ -1,136 +1,42 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
+ import { hardFilter } from './src/filter/hard-filter.js';
5
+ import { classify } from './src/filter/classifier.js';
6
+ import { loadModel } from './src/filter/train.js';
4
7
 
5
- // match /skill-name but not URLs (http://, https://, etc.)
6
8
  const SKILL_REF_RE = /(?<!https?:|ftp:)(?<![a-zA-Z0-9])\/([a-z0-9][a-z0-9-]{2,})/g;
7
9
 
8
- // Filenames that are never skills — docs, meta, legal files
9
- const SKIP_FILENAMES = new Set([
10
- 'readme', 'changelog', 'license', 'contributing', 'code-of-conduct',
11
- 'security', 'authors', 'credits', 'install', 'installation', 'usage',
12
- 'engagements', 'contributors', 'maintainers', 'acknowledgements',
13
- 'faq', 'glossary', 'index', 'overview', 'summary', 'roadmap', 'todo',
14
- 'notes', 'template', 'example', 'sample', 'demo', 'getting-started',
15
- 'quickstart', 'guide', 'tutorial', 'walkthrough', 'architecture',
16
- 'design', 'spec', 'specification', 'requirements', 'privacy', 'terms',
17
- 'disclaimer', 'notice', 'copying', 'warranty', 'codeofconduct',
18
- 'pull_request_template', 'issue_template', 'funding',
19
- ]);
20
-
21
- // Filename patterns that are never skills — readme* catches ALL variants (readme_de, readme_zh-CN, etc.)
22
- const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing|^code.of.conduct|^security|^authors|^credits|^disclaimer|^notice|^copying|^warranty|^promotion|^funding/i;
23
-
24
- // Path segments that indicate the file is NOT a skill
25
- const SKIP_DIRS = new Set([
26
- '.github', 'docs', 'doc', 'documentation', 'examples', 'example',
27
- 'tests', 'test', '__tests__', 'spec', 'fixtures', 'assets', 'images',
28
- 'img', 'screenshots', 'media', 'static', 'public', 'dist', 'build',
29
- 'node_modules', 'vendor', 'third_party',
30
- ]);
31
-
32
- // First-header values that signal documentation, not a skill
33
- const DOC_FIRST_HEADERS = /^(overview|introduction|about|background|welcome|getting started|what is|why |table of contents|toc|foreword|preface|readme)/i;
34
-
35
- // Imperative verbs commonly found in skill headers
36
- const IMPERATIVE_HEADERS = /\b(run|use|apply|execute|check|debug|fix|create|add|remove|deploy|test|write|generate|analyze|review|refactor|optimize|configure|setup|install|scan|audit|validate|search|find|extract|parse)\b/i;
37
-
38
- // Instructional section headers
39
- const INSTRUCTION_HEADERS = /^#{1,3}\s+(steps?|usage|instructions?|how\s+to|when\s+to\s+use|workflow|process|procedure|example|examples?|commands?|output|result)/i;
40
-
41
- // ── scoring ───────────────────────────────────────────────────────────────────
42
-
43
- function skillScore(raw, base) {
44
- let score = 0;
45
-
46
- // Fast path: frontmatter with name = definitely a skill
47
- try {
48
- const { data } = matter(raw);
49
- if (data.name && typeof data.name === 'string') return 10;
50
- } catch {}
51
-
52
- const lines = raw.split('\n');
53
- const nonEmpty = lines.filter(l => l.trim());
54
- const headers = nonEmpty.filter(l => /^#{1,3}\s/.test(l));
55
-
56
- // Minimum viable content
57
- if (raw.length < 150) return -99;
58
- if (headers.length < 1) return -99;
59
-
60
- // ── positive signals ──────────────────────────────────────────────────────
61
-
62
- // Instructional section names (## Steps, ## Usage, etc.)
63
- if (lines.some(l => INSTRUCTION_HEADERS.test(l))) score += 2;
64
-
65
- // Headers with imperative verbs
66
- if (headers.some(h => IMPERATIVE_HEADERS.test(h))) score += 2;
67
-
68
- // Code block
69
- if (raw.includes('```') || raw.includes(' ')) score += 1;
70
-
71
- // Numbered list (step-by-step)
72
- if (nonEmpty.some(l => /^\d+\.\s/.test(l))) score += 1;
73
-
74
- // Bullet list
75
- if (nonEmpty.some(l => /^[-*+]\s/.test(l))) score += 1;
76
-
77
- // Multiple headers (structure)
78
- if (headers.length >= 2) score += 1;
79
- if (headers.length >= 4) score += 1;
80
-
81
- // ── negative signals ──────────────────────────────────────────────────────
82
-
83
- // First header looks like documentation
84
- const firstHeader = headers[0]?.replace(/^#+\s*/, '') || '';
85
- if (DOC_FIRST_HEADERS.test(firstHeader)) score -= 3;
86
-
87
- // Content is mostly long prose paragraphs (narrative, not instructional)
88
- const paragraphs = raw.split(/\n\n+/).filter(p => p.trim() && !p.trim().startsWith('#'));
89
- const longProse = paragraphs.filter(p => p.split(' ').length > 60 && !/```/.test(p));
90
- if (longProse.length > paragraphs.length * 0.6 && paragraphs.length > 3) score -= 2;
91
-
92
- // Filename looks like a version, date, or index
93
- if (SKIP_FILENAME_RE.test(base)) score -= 3;
94
-
95
- // Very high word repetition (filler content)
96
- const words = raw.toLowerCase().split(/\s+/).filter(w => w.length > 3);
97
- if (words.length > 80) {
98
- const unique = new Set(words);
99
- if (unique.size / words.length < 0.22) score -= 2;
100
- }
101
-
102
- return score;
103
- }
104
-
105
- // ── public API ────────────────────────────────────────────────────────────────
106
-
107
10
  export function isSkillFile(filePath, raw) {
108
- const parts = filePath.replace(/\\/g, '/').split('/');
109
- const base = parts[parts.length - 1].replace(/\.md$/i, '').toLowerCase();
110
-
111
- // Hard-reject by filename
112
- if (SKIP_FILENAMES.has(base)) return false;
113
- if (SKIP_FILENAME_RE.test(base)) return false;
114
-
115
- // Hard-reject by parent directory
116
- for (const part of parts.slice(0, -1)) {
117
- if (SKIP_DIRS.has(part.toLowerCase())) return false;
118
- }
11
+ const result = hardFilter(filePath, raw);
12
+ if (!result.pass) return false;
119
13
 
120
14
  try {
121
15
  if (!raw) raw = fs.readFileSync(filePath, 'utf8');
122
-
123
- // Hard-reject: content starts with README header or badge lines
124
16
  const firstLines = raw.trimStart().slice(0, 300);
125
17
  if (/^#\s*readme\b/i.test(firstLines)) return false;
126
18
  if (/!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i.test(firstLines)) return false;
127
19
 
128
- return skillScore(raw, base) >= 3;
20
+ return true;
129
21
  } catch {
130
- return false;
22
+ return true;
131
23
  }
132
24
  }
133
25
 
26
+ export async function filterWithClassifier(skills) {
27
+ const centroids = loadModel();
28
+ if (!centroids) return skills;
29
+
30
+ const { embedBatch } = await import('./embedder.js');
31
+ const texts = skills.map(s => s.content || '');
32
+ const embeddings = await embedBatch(texts);
33
+
34
+ return skills.filter((_, i) => {
35
+ const decision = classify(embeddings[i], centroids, skills[i].content, skills[i].path);
36
+ return decision.label === 'skill' || decision.label === 'unsure';
37
+ });
38
+ }
39
+
134
40
  export function parseSkillFile(filePath, source, opts = {}) {
135
41
  const raw = opts.raw ?? fs.readFileSync(filePath, 'utf8');
136
42