promptgraph-mcp 2.4.8 → 2.6.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/api.js CHANGED
@@ -39,7 +39,8 @@ export async function index(sourceDir, sourceName) {
39
39
  const path = await import('path');
40
40
  const { createHash } = await import('crypto');
41
41
  const { parseSkillFile, isSkillFile } = await import('./parser.js');
42
- const { embedBatch, BATCH_SIZE } = await import('./embedder.js');
42
+ const { embedBatch } = await import('./embedder.js');
43
+ const { BATCH_SIZE } = await import('./config.js');
43
44
  const { skillId, vecToBlob } = await import('./db.js');
44
45
  const { chunkText } = await import('./chunker.js');
45
46
 
@@ -105,7 +106,8 @@ export async function update() {
105
106
  const path = await import('path');
106
107
  const { createHash } = await import('crypto');
107
108
  const { parseSkillFile, isSkillFile } = await import('./parser.js');
108
- const { embedBatch, BATCH_SIZE } = await import('./embedder.js');
109
+ const { embedBatch } = await import('./embedder.js');
110
+ const { BATCH_SIZE } = await import('./config.js');
109
111
  const { skillId, vecToBlob } = await import('./db.js');
110
112
  const { chunkText } = await import('./chunker.js');
111
113
  const { indexBatch } = await import('./indexer.js');
package/bundle-counts.js CHANGED
@@ -20,12 +20,13 @@ function httpsGet(url) {
20
20
  const headers = { 'User-Agent': 'promptgraph-mcp' };
21
21
  if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
22
22
  return new Promise((res, rej) => {
23
- const req = https.get(url, { headers }, r => {
23
+ const req = https.get(url, { headers, timeout: 15000 }, r => {
24
24
  if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
25
25
  if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
26
- let d = ''; r.setEncoding('utf8'); r.on('data', c => d += c); r.on('end', () => res(d));
26
+ const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
27
27
  });
28
28
  req.on('error', rej);
29
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
29
30
  });
30
31
  }
31
32
 
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
@@ -7,96 +7,138 @@ const DB_PATH = path.join(PROMPTGRAPH_DIR, 'promptgraph.db');
7
7
 
8
8
  let _db = null;
9
9
 
10
- export function getDb() {
11
- if (_db) return _db;
12
- fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
13
- const db = new Database(DB_PATH);
14
- _db = db;
15
- db.pragma('journal_mode = WAL');
16
-
17
- db.exec(`
18
- CREATE TABLE IF NOT EXISTS skills (
19
- id TEXT PRIMARY KEY,
20
- name TEXT NOT NULL,
21
- description TEXT,
22
- path TEXT NOT NULL,
23
- source TEXT NOT NULL,
24
- content TEXT NOT NULL,
25
- hash TEXT
26
- );
27
-
28
- CREATE TABLE IF NOT EXISTS chunks (
29
- id INTEGER PRIMARY KEY AUTOINCREMENT,
30
- skill_id TEXT NOT NULL,
31
- chunk_index INTEGER NOT NULL,
32
- text TEXT NOT NULL,
33
- embedding BLOB NOT NULL,
34
- UNIQUE(skill_id, chunk_index)
35
- );
36
-
37
- CREATE TABLE IF NOT EXISTS edges (
38
- from_skill TEXT NOT NULL,
39
- to_skill TEXT NOT NULL,
40
- PRIMARY KEY (from_skill, to_skill)
41
- );
42
-
43
- CREATE TABLE IF NOT EXISTS ratings (
44
- skill_id TEXT PRIMARY KEY,
45
- uses INTEGER DEFAULT 0,
46
- success INTEGER DEFAULT 0,
47
- fail INTEGER DEFAULT 0
48
- );
49
-
50
- CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
51
- id UNINDEXED,
52
- name,
53
- description,
54
- content,
55
- content='skills',
56
- content_rowid='rowid'
57
- );
58
- `);
10
+ const MIGRATIONS = [
11
+ {
12
+ version: 1, description: 'initial schema',
13
+ up: db => db.exec(`
14
+ CREATE TABLE IF NOT EXISTS skills (
15
+ id TEXT PRIMARY KEY,
16
+ name TEXT NOT NULL,
17
+ description TEXT,
18
+ path TEXT NOT NULL,
19
+ source TEXT NOT NULL,
20
+ content TEXT NOT NULL,
21
+ hash TEXT
22
+ );
23
+ CREATE TABLE IF NOT EXISTS chunks (
24
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
25
+ skill_id TEXT NOT NULL,
26
+ chunk_index INTEGER NOT NULL,
27
+ text TEXT NOT NULL,
28
+ embedding BLOB NOT NULL,
29
+ UNIQUE(skill_id, chunk_index)
30
+ );
31
+ CREATE TABLE IF NOT EXISTS edges (
32
+ from_skill TEXT NOT NULL,
33
+ to_skill TEXT NOT NULL,
34
+ PRIMARY KEY (from_skill, to_skill)
35
+ );
36
+ CREATE TABLE IF NOT EXISTS ratings (
37
+ skill_id TEXT PRIMARY KEY,
38
+ uses INTEGER DEFAULT 0,
39
+ success INTEGER DEFAULT 0,
40
+ fail INTEGER DEFAULT 0
41
+ );
42
+ CREATE VIRTUAL TABLE IF NOT EXISTS skills_fts USING fts5(
43
+ id UNINDEXED, name, description, content,
44
+ content='skills', content_rowid='rowid'
45
+ );
46
+ `),
47
+ },
48
+ {
49
+ version: 2, description: 'add hash column',
50
+ up: db => {
51
+ const cols = db.pragma('table_info(skills)').map(c => c.name);
52
+ if (!cols.includes('hash')) db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
53
+ },
54
+ },
55
+ {
56
+ version: 3, description: 'add registry metadata columns',
57
+ up: db => {
58
+ const cols = db.pragma('table_info(skills)').map(c => c.name);
59
+ for (const [col, def] of [
60
+ ['version', 'TEXT'],
61
+ ['author', 'TEXT'],
62
+ ['license', 'TEXT'],
63
+ ['updated_at', 'TEXT'],
64
+ ['downloads', 'INTEGER DEFAULT 0'],
65
+ ['verified', 'INTEGER DEFAULT 0'],
66
+ ['trust_level', "TEXT DEFAULT 'unknown'"],
67
+ ['rating', 'REAL DEFAULT 0'],
68
+ ['rating_count', 'INTEGER DEFAULT 0'],
69
+ ['popularity', 'REAL DEFAULT 0'],
70
+ ['last_update', 'TEXT'],
71
+ ]) {
72
+ if (!cols.includes(col)) db.exec(`ALTER TABLE skills ADD COLUMN ${col} ${def}`);
73
+ }
74
+ },
75
+ },
76
+ {
77
+ version: 4, description: 'registry_entries table',
78
+ up: db => db.exec(`
79
+ CREATE TABLE IF NOT EXISTS registry_entries (
80
+ id TEXT PRIMARY KEY,
81
+ trust_level TEXT DEFAULT 'unknown',
82
+ downloads INTEGER DEFAULT 0,
83
+ rating REAL DEFAULT 0,
84
+ rating_count INTEGER DEFAULT 0,
85
+ popularity REAL DEFAULT 0,
86
+ last_update TEXT
87
+ );
88
+ `),
89
+ },
90
+ {
91
+ version: 5, description: 'convert TEXT embeddings to Float32 BLOB',
92
+ up: db => {
93
+ const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
94
+ if (textEmbeddings?.n > 0) {
95
+ const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
96
+ const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
97
+ db.transaction(() => {
98
+ for (const row of rows) {
99
+ const vec = JSON.parse(row.embedding);
100
+ upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
101
+ }
102
+ })();
103
+ console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
104
+ }
105
+ },
106
+ },
107
+ ]
59
108
 
60
- // migrate: add hash column if missing
61
- const cols = db.pragma('table_info(skills)').map(c => c.name);
62
- if (!cols.includes('hash')) {
63
- db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
109
+ function getSchemaVersion(db) {
110
+ try {
111
+ return db.prepare('SELECT MAX(version) as v FROM _schema_version').get().v || 0
112
+ } catch {
113
+ return 0
64
114
  }
115
+ }
65
116
 
66
- // migrate: add registry metadata columns
67
- if (!cols.includes('version')) {
68
- db.exec('ALTER TABLE skills ADD COLUMN version TEXT');
69
- }
70
- if (!cols.includes('author')) {
71
- db.exec('ALTER TABLE skills ADD COLUMN author TEXT');
72
- }
73
- if (!cols.includes('license')) {
74
- db.exec('ALTER TABLE skills ADD COLUMN license TEXT');
75
- }
76
- if (!cols.includes('updated_at')) {
77
- db.exec('ALTER TABLE skills ADD COLUMN updated_at TEXT');
78
- }
79
- if (!cols.includes('downloads')) {
80
- db.exec('ALTER TABLE skills ADD COLUMN downloads INTEGER DEFAULT 0');
81
- }
82
- if (!cols.includes('verified')) {
83
- db.exec('ALTER TABLE skills ADD COLUMN verified INTEGER DEFAULT 0');
84
- }
117
+ function runMigrations(db) {
118
+ const current = getSchemaVersion(db)
119
+ const pending = MIGRATIONS.filter(m => m.version > current).sort((a, b) => a.version - b.version)
120
+ if (!pending.length) return
121
+
122
+ db.exec(`CREATE TABLE IF NOT EXISTS _schema_version (version INTEGER PRIMARY KEY, description TEXT, applied_at TEXT)`)
85
123
 
86
- // migrate: convert JSON text embeddings to Float32 BLOB (one-time, ~10x smaller)
87
- const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
88
- if (textEmbeddings?.n > 0) {
89
- const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
90
- const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
124
+ for (const migration of pending) {
91
125
  db.transaction(() => {
92
- for (const row of rows) {
93
- const vec = JSON.parse(row.embedding);
94
- upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
95
- }
96
- })();
97
- console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
126
+ migration.up(db)
127
+ db.prepare('INSERT INTO _schema_version (version, description, applied_at) VALUES (?, ?, ?)').run(
128
+ migration.version, migration.description, new Date().toISOString()
129
+ )
130
+ })()
131
+ console.error(`[PromptGraph] DB migrated to v${migration.version}: ${migration.description}`)
98
132
  }
133
+ }
99
134
 
135
+ export function getDb() {
136
+ if (_db) return _db;
137
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
138
+ const db = new Database(DB_PATH);
139
+ _db = db;
140
+ db.pragma('journal_mode = WAL');
141
+ runMigrations(db);
100
142
  return db;
101
143
  }
102
144
 
package/github-import.js CHANGED
@@ -4,24 +4,71 @@ 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, redirects = 0) {
29
+ if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
30
+ return new Promise((res, rej) => {
31
+ const token = process.env.GITHUB_TOKEN;
32
+ const headers = { 'User-Agent': 'promptgraph-mcp' };
33
+ if (token && url.startsWith('https://raw.')) headers['Authorization'] = `Bearer ${token}`;
34
+ const req = https.get(url, { headers }, r => {
35
+ if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
36
+ return streamDownload(r.headers.location, maxSize, redirects + 1).then(res, rej);
37
+ if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
38
+ const cl = parseInt(r.headers['content-length'], 10);
39
+ if (!isNaN(cl) && cl > maxSize) {
40
+ r.resume();
41
+ return rej(new Error(`Content-Length ${cl} exceeds max ${maxSize}`));
42
+ }
43
+ const chunks = []
44
+ let total = 0
45
+ r.setEncoding('utf8')
46
+ r.on('data', c => {
47
+ total += Buffer.byteLength(c, 'utf8')
48
+ if (total > maxSize) {
49
+ r.destroy()
50
+ return rej(new Error(`Download exceeded ${maxSize} bytes`))
51
+ }
52
+ chunks.push(c)
53
+ })
54
+ r.on('end', () => res(chunks.join('')))
55
+ })
56
+ req.on('error', rej)
57
+ })
58
+ }
59
+
60
+ async function httpsGet(url, redirects = 0) {
61
+ if (redirects > 5) return Promise.reject(new Error('Too many redirects'))
62
+ await githubRateLimiter.acquire()
16
63
  const token = process.env.GITHUB_TOKEN;
17
64
  const headers = { 'User-Agent': 'promptgraph-mcp' };
18
65
  if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
19
66
  return new Promise((res, rej) => {
20
67
  const req = https.get(url, { headers }, r => {
21
68
  if (r.statusCode >= 300 && r.statusCode < 400 && r.headers.location)
22
- return httpsGet(r.headers.location).then(res, rej);
69
+ return httpsGet(r.headers.location, redirects + 1).then(res, rej);
23
70
  if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
24
- let d = ''; r.setEncoding('utf8'); r.on('data', c => d += c); r.on('end', () => res(d));
71
+ const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
25
72
  });
26
73
  req.on('error', rej);
27
74
  });
@@ -39,11 +86,22 @@ function repoExists(ownerRepo) {
39
86
  }
40
87
 
41
88
  // Download one .md file, run validateSkill on it, return errors/warnings.
42
- async function validateMdFile(file, tmpDir) {
89
+ async function validateMdFile(file, tmpDir, ownerRepo) {
43
90
  const errors = [];
44
91
  const warnings = [];
92
+ const stats = getRepoStats(ownerRepo);
45
93
  try {
46
- const content = await httpsGet(file.download_url);
94
+ if (stats.fileCount >= MAX_FILE_COUNT) {
95
+ errors.push(`${file.name}: skipped — repo file count limit (${MAX_FILE_COUNT}) reached`);
96
+ return { errors, warnings };
97
+ }
98
+ await downloadRateLimiter.acquire()
99
+ const content = await streamDownload(file.download_url);
100
+ stats.totalBytes += Buffer.byteLength(content, 'utf8');
101
+ stats.fileCount++;
102
+ if (stats.totalBytes > MAX_REPO_SIZE) {
103
+ return { errors: [...errors, `${file.name}: repo size limit (${MAX_REPO_SIZE}) exceeded`], warnings };
104
+ }
47
105
  const tmpPath = path.join(tmpDir, file.name);
48
106
  fs.mkdirSync(path.dirname(tmpPath), { recursive: true });
49
107
  fs.writeFileSync(tmpPath, content);
@@ -119,7 +177,7 @@ export async function validateRepoSkills(ownerRepo) {
119
177
  let warnings = [];
120
178
 
121
179
  for (const file of mdToValidate) {
122
- const r = await validateMdFile(file, tmpDir);
180
+ const r = await validateMdFile(file, tmpDir, ownerRepo);
123
181
  errors.push(...r.errors);
124
182
  warnings.push(...r.warnings);
125
183
  }
@@ -464,6 +522,10 @@ export async function importFromGitHub(repoUrl) {
464
522
  const label = skillsSubdir || localLabel;
465
523
  const mdFiles = globSync(`${skillsDir}/**/*.md`);
466
524
 
525
+ if (mdFiles.length > MAX_FILE_COUNT) {
526
+ console.warn(`Warning: ${mdFiles.length} .md files exceeds limit of ${MAX_FILE_COUNT} — truncating`);
527
+ }
528
+
467
529
  if (skillsSubdir) {
468
530
  console.log(`Sparse-checkout: ${label}/ only (${mdFiles.length} .md files, no other repo files)`);
469
531
  } else {