promptgraph-mcp 2.4.8 → 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.
package/config.js CHANGED
@@ -8,6 +8,13 @@ export const PROMPTGRAPH_DIR = path.join(CLAUDE_DIR, '.promptgraph');
8
8
  export const SKILLS_STORE_DIR = path.join(CLAUDE_DIR, 'skills-store');
9
9
  const CONFIG_PATH = path.join(PROMPTGRAPH_DIR, 'config.json');
10
10
 
11
+ export const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024 // 50 MB per file
12
+ export const MAX_FILE_COUNT = 50000 // max files per repo
13
+ export const MAX_REPO_SIZE = 500 * 1024 * 1024 // 500 MB per repo
14
+ export const RATE_LIMIT_REQUESTS = 30 // requests per window
15
+ export const RATE_LIMIT_WINDOW_MS = 60000 // 1 minute window
16
+ export const BATCH_SIZE = 100 // batch indexing size
17
+
11
18
  const DEFAULTS = {
12
19
  sources: [
13
20
  { dir: path.join(CLAUDE_DIR, 'skills-store'), source: 'skills-store' },
package/db.js CHANGED
@@ -82,6 +82,34 @@ export function getDb() {
82
82
  if (!cols.includes('verified')) {
83
83
  db.exec('ALTER TABLE skills ADD COLUMN verified INTEGER DEFAULT 0');
84
84
  }
85
+ if (!cols.includes('trust_level')) {
86
+ db.exec('ALTER TABLE skills ADD COLUMN trust_level TEXT DEFAULT \'unknown\'');
87
+ }
88
+ if (!cols.includes('rating')) {
89
+ db.exec('ALTER TABLE skills ADD COLUMN rating REAL DEFAULT 0');
90
+ }
91
+ if (!cols.includes('rating_count')) {
92
+ db.exec('ALTER TABLE skills ADD COLUMN rating_count INTEGER DEFAULT 0');
93
+ }
94
+ if (!cols.includes('popularity')) {
95
+ db.exec('ALTER TABLE skills ADD COLUMN popularity REAL DEFAULT 0');
96
+ }
97
+ if (!cols.includes('last_update')) {
98
+ db.exec('ALTER TABLE skills ADD COLUMN last_update TEXT');
99
+ }
100
+
101
+ // migrate: registry entries metadata table
102
+ db.exec(`
103
+ CREATE TABLE IF NOT EXISTS registry_entries (
104
+ id TEXT PRIMARY KEY,
105
+ trust_level TEXT DEFAULT 'unknown',
106
+ downloads INTEGER DEFAULT 0,
107
+ rating REAL DEFAULT 0,
108
+ rating_count INTEGER DEFAULT 0,
109
+ popularity REAL DEFAULT 0,
110
+ last_update TEXT
111
+ );
112
+ `);
85
113
 
86
114
  // migrate: convert JSON text embeddings to Float32 BLOB (one-time, ~10x smaller)
87
115
  const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
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,11 +84,22 @@ 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);
48
104
  fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
49
105
  fs.writeFileSync(tmpPath, content);
@@ -119,7 +175,7 @@ export async function validateRepoSkills(ownerRepo) {
119
175
  let warnings = [];
120
176
 
121
177
  for (const file of mdToValidate) {
122
- const r = await validateMdFile(file, tmpDir);
178
+ const r = await validateMdFile(file, tmpDir, ownerRepo);
123
179
  errors.push(...r.errors);
124
180
  warnings.push(...r.warnings);
125
181
  }
@@ -464,6 +520,10 @@ export async function importFromGitHub(repoUrl) {
464
520
  const label = skillsSubdir || localLabel;
465
521
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
466
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
+
467
527
  if (skillsSubdir) {
468
528
  console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
469
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';
@@ -210,6 +211,7 @@ export async function indexAll({ fast = false } = {}) {
210
211
  batch = [];
211
212
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
212
213
  progress(count, total, { skipped, eta, errors });
214
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
213
215
  }
214
216
  } catch (e) {
215
217
  errors++;
@@ -301,6 +303,7 @@ export async function indexSource(dir, sourceName) {
301
303
  await indexBatch(db, batch);
302
304
  count += batch.length; batch = [];
303
305
  progress(count, total, { skipped, errors });
306
+ await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
304
307
  }
305
308
  } catch (e) { errors++; count++; }
306
309
  }
package/marketplace.js CHANGED
@@ -61,6 +61,16 @@ function validateRegistryEntry(item) {
61
61
  }
62
62
  if (item.downloads !== undefined && typeof item.downloads !== 'number') errors.push('downloads must be a number');
63
63
  if (item.verified !== undefined && typeof item.verified !== 'boolean') errors.push('verified must be a boolean');
64
+ if (item.trustLevel !== undefined && typeof item.trustLevel !== 'string') errors.push('trustLevel must be a string');
65
+ if (item.rating !== undefined) {
66
+ if (typeof item.rating !== 'number') errors.push('rating must be a number');
67
+ else if (item.rating < 0 || item.rating > 5) errors.push('rating must be between 0 and 5');
68
+ }
69
+ if (item.popularity !== undefined && typeof item.popularity !== 'number') errors.push('popularity must be a number');
70
+ if (item.lastUpdate !== undefined) {
71
+ if (typeof item.lastUpdate !== 'string') errors.push('lastUpdate must be a string');
72
+ else if (isNaN(Date.parse(item.lastUpdate))) errors.push(`lastUpdate "${item.lastUpdate}" is not a valid ISO date`);
73
+ }
64
74
  if (errors.length > 0) {
65
75
  console.warn(`[PromptGraph] Skipping invalid registry entry "${item.id || item.name || '(unnamed)'}": ${errors.join('; ')}`);
66
76
  return { ok: false };
@@ -162,7 +172,7 @@ export async function browseMarketplace(topK = 20) {
162
172
  const registry = JSON.parse(text);
163
173
  if (!Array.isArray(registry.skills)) return { error: 'Invalid registry format' };
164
174
  return registry.skills
165
- .map(s => ({ ...s, code: s.code || codeFor(s.id) })) // auto-fill code
175
+ .map(s => Object.assign(Object.create(null), s, { code: s.code || codeFor(s.id) }))
166
176
  .filter(s => validateRegistryEntry(s).ok)
167
177
  .filter(s => s.raw_url)
168
178
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
@@ -287,7 +297,7 @@ export async function browseBundles(topK = 20) {
287
297
 
288
298
  return bundles
289
299
  .filter(b => !b.repo_url || b.skillCount > 0)
290
- .map(b => ({ ...b, code: b.code || codeFor(b.id) }))
300
+ .map(b => Object.assign(Object.create(null), b, { code: b.code || codeFor(b.id) }))
291
301
  .sort((a, b) => (b.stars || 0) - (a.stars || 0))
292
302
  .slice(0, topK);
293
303
  } catch (e) {
@@ -548,3 +558,74 @@ export function pruneInvalidRepos() {
548
558
  saveConfig(config);
549
559
  return { removed, kept, errors };
550
560
  }
561
+
562
+ // ── Trust level system ─────────────────────────────────────────────────────────
563
+ const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
564
+
565
+ function calcPopularity(downloads = 0, rating = 0) {
566
+ return Math.round((downloads || 0) * ((rating || 0) + 1) * 100) / 100
567
+ }
568
+
569
+ export async function setTrustLevel(name, level) {
570
+ const db = getDb()
571
+ if (!VALID_TRUST_LEVELS.includes(level)) {
572
+ return { ok: false, error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
573
+ }
574
+ const id = String(name)
575
+ db.prepare(`
576
+ INSERT INTO registry_entries (id, trust_level, last_update)
577
+ VALUES (?, ?, datetime('now'))
578
+ ON CONFLICT(id) DO UPDATE SET trust_level = excluded.trust_level, last_update = excluded.last_update
579
+ `).run(id, level)
580
+ return { ok: true }
581
+ }
582
+
583
+ export async function getByTrustLevel(level) {
584
+ const db = getDb()
585
+ if (level && !VALID_TRUST_LEVELS.includes(level)) {
586
+ return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
587
+ }
588
+ if (level) {
589
+ return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY id').all(level)
590
+ }
591
+ return db.prepare('SELECT * FROM registry_entries ORDER BY id').all()
592
+ }
593
+
594
+ export async function incrementDownloads(name) {
595
+ const db = getDb()
596
+ const id = String(name)
597
+ const existing = db.prepare('SELECT downloads, rating FROM registry_entries WHERE id = ?').get(id)
598
+ if (existing) {
599
+ const newDownloads = (existing.downloads || 0) + 1
600
+ const pop = calcPopularity(newDownloads, existing.rating || 0)
601
+ db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
602
+ .run(newDownloads, pop, id)
603
+ } else {
604
+ const pop = calcPopularity(1, 0)
605
+ db.prepare('INSERT INTO registry_entries (id, downloads, popularity, last_update) VALUES (?, 1, ?, datetime(\'now\'))')
606
+ .run(id, pop)
607
+ }
608
+ return { ok: true }
609
+ }
610
+
611
+ export async function rateSkill(name, rating) {
612
+ const db = getDb()
613
+ if (typeof rating !== 'number' || rating < 0 || rating > 5) {
614
+ return { ok: false, error: 'Rating must be a number between 0 and 5' }
615
+ }
616
+ const id = String(name)
617
+ const existing = db.prepare('SELECT rating, rating_count, downloads FROM registry_entries WHERE id = ?').get(id)
618
+ if (existing) {
619
+ const count = existing.rating_count || 0
620
+ const newRating = Math.round(((existing.rating || 0) * count + rating) / (count + 1) * 100) / 100
621
+ const newCount = count + 1
622
+ const pop = calcPopularity(existing.downloads || 0, newRating)
623
+ db.prepare('UPDATE registry_entries SET rating = ?, rating_count = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
624
+ .run(newRating, newCount, pop, id)
625
+ } else {
626
+ const pop = calcPopularity(0, rating)
627
+ db.prepare('INSERT INTO registry_entries (id, rating, rating_count, popularity, last_update) VALUES (?, ?, 1, ?, datetime(\'now\'))')
628
+ .run(id, rating, pop)
629
+ }
630
+ return { ok: true }
631
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.4.8",
3
+ "version": "2.5.0",
4
4
  "files": [
5
5
  "*.js",
6
6
  "src/**/*.js",
@@ -55,6 +55,6 @@
55
55
  "vitest": "^4.1.8"
56
56
  },
57
57
  "overrides": {
58
- "tar": "^6.2.1"
58
+ "tar": "^7.5.11"
59
59
  }
60
60
  }
package/search.js CHANGED
@@ -2,24 +2,10 @@ 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
5
+ function getHybridWeights(query) {
15
6
  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
- }
7
+ if (hasTechTerms) return { embedWeight: 0.5, bm25Weight: 0.5 }
8
+ return { embedWeight: 0.7, bm25Weight: 0.3 }
23
9
  }
24
10
 
25
11
  function applyRatingBoost(db, id, score) {
@@ -82,7 +68,7 @@ function runBM25Search(db, query, topK) {
82
68
 
83
69
  export async function search(query, topK = 5) {
84
70
  const db = getDb();
85
- adaptWeights(query);
71
+ const { embedWeight, bm25Weight } = getHybridWeights(query);
86
72
  const queryVec = await embed(query);
87
73
 
88
74
  const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
@@ -116,13 +102,29 @@ export async function search(query, topK = 5) {
116
102
  }
117
103
  }
118
104
 
119
- return [...combined.entries()]
105
+ const ordered = [...combined.entries()]
120
106
  .map(([id, s]) => ({
121
107
  id,
122
108
  score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
123
109
  }))
124
110
  .sort((a, b) => b.score - a.score)
125
- .slice(0, topK)
111
+
112
+ const rerankerEnabled = process.env.PG_RERANKER !== '0'
113
+
114
+ if (rerankerEnabled) {
115
+ const { Reranker } = await import('./src/reranker/reranker.js')
116
+ const reranker = new Reranker()
117
+ const topN = ordered.slice(0, 20)
118
+ .map(({ id, score }) => {
119
+ const s = skillWithSnippet(db, id, score)
120
+ return s ? { id, text: s.snippet, score } : null
121
+ })
122
+ .filter(Boolean)
123
+ const reranked = await reranker.rerank(query, topN)
124
+ return reranked.map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score))).filter(Boolean)
125
+ }
126
+
127
+ return ordered.slice(0, topK)
126
128
  .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
127
129
  .filter(Boolean);
128
130
  }
@@ -0,0 +1,27 @@
1
+ export class Reranker {
2
+ constructor(modelName = 'default', device = 'cpu') {
3
+ this.modelName = modelName
4
+ this.device = device
5
+ }
6
+
7
+ // Cross-encoder style reranker
8
+ // Plug-in point for BGE Reranker / MiniLM Reranker via ONNX
9
+ async rerank(query, results) {
10
+ if (!results || results.length === 0) return []
11
+
12
+ const queryTerms = (query.toLowerCase().match(/\w+/g) || [])
13
+ const queryTermCount = queryTerms.length || 1
14
+
15
+ const scored = results.map(r => {
16
+ const text = (r.text || '').toLowerCase()
17
+ const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
18
+
19
+ return {
20
+ ...r,
21
+ score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
22
+ }
23
+ })
24
+
25
+ return scored.sort((a, b) => b.score - a.score).slice(0, 5)
26
+ }
27
+ }
@@ -0,0 +1,33 @@
1
+ export class RateLimiter {
2
+ constructor({ maxRequests, windowMs }) {
3
+ this.maxRequests = maxRequests
4
+ this.windowMs = windowMs
5
+ this.timestamps = []
6
+ this._chain = Promise.resolve()
7
+ }
8
+
9
+ async acquire() {
10
+ const prev = this._chain
11
+ this._chain = prev.then(() => this._doAcquire())
12
+ return this._chain
13
+ }
14
+
15
+ async _doAcquire() {
16
+ while (!this.tryAcquire()) {
17
+ const oldest = this.timestamps[0]
18
+ const waitMs = oldest
19
+ ? Math.max(1, oldest + this.windowMs - Date.now())
20
+ : 100
21
+ await new Promise(r => setTimeout(r, Math.min(waitMs, this.windowMs)))
22
+ }
23
+ }
24
+
25
+ tryAcquire() {
26
+ const now = Date.now()
27
+ const cutoff = now - this.windowMs
28
+ this.timestamps = this.timestamps.filter(t => t > cutoff)
29
+ if (this.timestamps.length >= this.maxRequests) return false
30
+ this.timestamps.push(now)
31
+ return true
32
+ }
33
+ }
package/validator.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'fs';
2
+ import path from 'path';
2
3
  import matter from 'gray-matter';
4
+ import { MAX_DOWNLOAD_SIZE } from './config.js';
3
5
 
4
6
  // patterns that indicate malicious or junk skills
5
7
  const DANGEROUS_PATTERNS = [
@@ -86,6 +88,19 @@ export function validateSkill(filePath) {
86
88
  if (pathParts.includes('..')) {
87
89
  errors.push('Path traversal detected: file path contains ".."');
88
90
  }
91
+
92
+ // extension whitelist — reject unexpected file types
93
+ const ALLOWED_EXTENSIONS = new Set(['.md', '.json', '.yaml', '.yml', '.txt', '.js', '.ts', '.py', '.rs', '.toml']);
94
+ const ext = path.extname(filePath).toLowerCase();
95
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
96
+ errors.push(`File extension "${ext}" not allowed. Allowed: ${[...ALLOWED_EXTENSIONS].join(', ')}`);
97
+ }
98
+
99
+ // binary content detection — must be valid UTF-8 with no null bytes
100
+ if (raw.includes('\0')) {
101
+ errors.push('File contains null bytes (binary content)');
102
+ }
103
+
89
104
  if (['readme.md', 'changelog.md', 'license.md', 'contributing.md'].includes(base)) {
90
105
  warnings.push('Filename looks like a docs file, not a skill.');
91
106
  }
@@ -142,6 +157,15 @@ export function validateBundle(def) {
142
157
  return { ok: errors.length === 0, errors, warnings };
143
158
  }
144
159
 
160
+ export function sanitizeExternalContent(content) {
161
+ if (typeof content !== 'string') return ''
162
+ let sanitized = content.replace(/\0/g, '')
163
+ if (Buffer.byteLength(sanitized, 'utf8') > MAX_DOWNLOAD_SIZE) {
164
+ sanitized = Buffer.from(sanitized, 'utf8').subarray(0, MAX_DOWNLOAD_SIZE).toString('utf8')
165
+ }
166
+ return sanitized
167
+ }
168
+
145
169
  // CLI: node validator.js <file>
146
170
  if (import.meta.url === `file://${process.argv[1]}`) {
147
171
  const file = process.argv[2];
@@ -1,52 +0,0 @@
1
- import { cosineSimilarity } from '../../embedder.js';
2
-
3
- const INFERENCE_THRESHOLD = 0.35;
4
-
5
- export function findClusters(skills, embeddings) {
6
- if (!skills.length || !embeddings) return [];
7
- const clusters = [];
8
- const assigned = new Set();
9
-
10
- for (let i = 0; i < skills.length; i++) {
11
- if (assigned.has(i)) continue;
12
- const cluster = { centroid: embeddings[i], members: [skills[i]], indices: [i] };
13
- assigned.add(i);
14
-
15
- for (let j = i + 1; j < skills.length; j++) {
16
- if (assigned.has(j)) continue;
17
- if (cosineSimilarity(embeddings[i], embeddings[j]) >= INFERENCE_THRESHOLD) {
18
- cluster.members.push(skills[j]);
19
- cluster.indices.push(j);
20
- assigned.add(j);
21
- }
22
- }
23
-
24
- clusters.push(cluster);
25
- }
26
-
27
- return clusters;
28
- }
29
-
30
- export function expandResults(annResults, db) {
31
- if (!annResults || annResults.length === 0) return annResults;
32
-
33
- const seenIds = new Set();
34
- const expanded = [];
35
-
36
- for (const r of annResults) {
37
- if (!seenIds.has(r.skill_id)) {
38
- seenIds.add(r.skill_id);
39
- expanded.push(r);
40
- }
41
-
42
- const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(r.skill_id);
43
- for (const c of callees) {
44
- if (!seenIds.has(c.to_skill)) {
45
- seenIds.add(c.to_skill);
46
- expanded.push({ skill_id: c.to_skill, score: r.score * 0.85 });
47
- }
48
- }
49
- }
50
-
51
- return expanded.sort((a, b) => b.score - a.score);
52
- }
@@ -1,36 +0,0 @@
1
- import { cosineSimilarity } from '../../embedder.js';
2
-
3
- const DEDUP_THRESHOLD = 0.97;
4
-
5
- export function dedup(skills, embeddings) {
6
- if (!skills.length) return { skills: [], embeddings: [] };
7
- if (embeddings && embeddings.length !== skills.length) {
8
- throw new Error('embeddings length must match skills length');
9
- }
10
-
11
- const keep = [true]; // first skill always kept
12
- const kept = [0];
13
-
14
- for (let i = 1; i < skills.length; i++) {
15
- let dup = false;
16
- if (embeddings) {
17
- for (const j of kept) {
18
- if (cosineSimilarity(embeddings[i], embeddings[j]) >= DEDUP_THRESHOLD) {
19
- dup = true;
20
- break;
21
- }
22
- }
23
- }
24
- if (dup) {
25
- keep.push(false);
26
- } else {
27
- keep.push(true);
28
- kept.push(i);
29
- }
30
- }
31
-
32
- return {
33
- skills: skills.filter((_, i) => keep[i]),
34
- embeddings: embeddings ? embeddings.filter((_, i) => keep[i]) : undefined,
35
- };
36
- }