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/README.md +3 -6
- package/ann.js +23 -51
- package/api.js +200 -0
- package/config.js +7 -0
- package/db.js +20 -0
- package/embedder.js +10 -0
- package/github-import.js +106 -26
- package/index.js +47 -14
- package/indexer.js +59 -18
- package/marketplace.js +47 -5
- package/package.json +9 -3
- package/parser.js +21 -115
- package/search.js +84 -20
- package/src/filter/classifier.js +88 -0
- package/src/filter/cluster.js +52 -0
- package/src/filter/dedup.js +36 -0
- package/src/filter/hard-filter.js +59 -0
- package/src/filter/train.js +66 -0
- package/src/store/flat-store.js +61 -0
- package/src/store/hnsw-store.js +187 -0
- package/src/store/index.js +19 -0
- package/src/store/vector-store.js +9 -0
- package/validator.js +7 -1
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 {
|
|
325
|
-
const
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const
|
|
339
|
-
if (
|
|
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 =
|
|
118
|
+
const norm = sanitizePath(f);
|
|
102
119
|
if (!seenFiles.has(norm)) {
|
|
103
120
|
seenFiles.add(norm);
|
|
104
|
-
allFiles.push({ file:
|
|
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 {
|
|
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
|
|
178
|
-
|
|
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
|
|
200
|
-
|
|
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
|
-
|
|
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(
|
|
257
|
+
const raw = fs.readFileSync(safe, 'utf8');
|
|
220
258
|
const hash = createHash('md5').update(raw).digest('hex');
|
|
221
|
-
const skill = parseSkillFile(
|
|
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
|
|
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 =
|
|
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
|
|
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 =
|
|
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.
|
|
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
|
|
109
|
-
|
|
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
|
|
20
|
+
return true;
|
|
129
21
|
} catch {
|
|
130
|
-
return
|
|
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
|
|