promptgraph-mcp 1.5.25 → 1.7.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/README.md +21 -5
- package/ann.js +46 -47
- package/chunker.js +16 -7
- package/config.js +3 -1
- package/db.js +27 -3
- package/index.js +137 -8
- package/indexer.js +4 -14
- package/marketplace.js +134 -40
- package/package.json +3 -4
- package/pg-hook.js +29 -13
- package/platform.js +61 -39
- package/search.js +13 -11
- package/validator.js +41 -0
- package/watcher.js +5 -2
package/README.md
CHANGED
|
@@ -11,13 +11,13 @@ PromptGraph is an MCP server that gives Claude Code a semantic skill index — v
|
|
|
11
11
|
|
|
12
12
|
## Why
|
|
13
13
|
|
|
14
|
-
Claude Code
|
|
14
|
+
Claude Code loads skill metadata from `~/.claude/commands/` each session. With 40+ skills that's thousands of tokens in routing overhead before you say a word. More importantly, when Claude loads and executes a full skill file (typically 1,000–5,000 tokens each), the cost multiplies fast.
|
|
15
15
|
|
|
16
|
-
PromptGraph replaces that with one tiny router skill (`~150 tokens`) and a local vector index.
|
|
16
|
+
PromptGraph replaces that with one tiny router skill (`~150 tokens`) and a local vector index. Claude calls `pg_search` → gets the right skill path + a content snippet → reads only that file when needed.
|
|
17
17
|
|
|
18
18
|
```
|
|
19
|
-
Before: all 40 skills
|
|
20
|
-
After: 1
|
|
19
|
+
Before: route across all 40 skills → 40 × read overhead
|
|
20
|
+
After: 1 pg_search call + snippet → read only what you need
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
---
|
|
@@ -31,7 +31,7 @@ After: 1 router + 1 match → ~300 tokens total
|
|
|
31
31
|
- ⚡ **Local embeddings** — `fastembed` BGE-Small-EN, 23 MB, no API key needed
|
|
32
32
|
- 👁️ **File watcher** — auto-reindexes when you add or edit skills
|
|
33
33
|
- 🛡️ **Validator** — blocks malicious/junk skills before they reach your machine
|
|
34
|
-
- 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, and any MCP client
|
|
34
|
+
- 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, OpenCode, Cursor, Windsurf, and any MCP client
|
|
35
35
|
|
|
36
36
|
---
|
|
37
37
|
|
|
@@ -61,6 +61,22 @@ npx promptgraph-mcp init
|
|
|
61
61
|
}
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
### Add to OpenCode (`~/.config/opencode/opencode.json`)
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"mcp": {
|
|
69
|
+
"promptgraph": {
|
|
70
|
+
"type": "local",
|
|
71
|
+
"command": ["npx", "promptgraph-mcp"],
|
|
72
|
+
"enabled": true
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> `pg setup` auto-detects OpenCode and writes this config for you.
|
|
79
|
+
|
|
64
80
|
### Move your skills out of `commands/`
|
|
65
81
|
|
|
66
82
|
```bash
|
package/ann.js
CHANGED
|
@@ -1,61 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
import { getDb, blobToVec } from './db.js';
|
|
2
|
+
|
|
3
|
+
// In-memory flat index — no external dependency.
|
|
4
|
+
// For typical skill counts (<5000) this is faster than vectra's disk-based HNSW
|
|
5
|
+
// because all data fits in RAM and no I/O is needed per query.
|
|
6
|
+
let _cache = null;
|
|
7
|
+
let _cacheChunkCount = -1;
|
|
8
|
+
|
|
9
|
+
function loadCache(db) {
|
|
10
|
+
const count = db.prepare('SELECT COUNT(*) as n FROM chunks').get().n;
|
|
11
|
+
if (_cache && count === _cacheChunkCount) return _cache;
|
|
12
|
+
const rows = db.prepare('SELECT skill_id, embedding FROM chunks').all();
|
|
13
|
+
_cache = rows.map(r => ({
|
|
14
|
+
skill_id: r.skill_id,
|
|
15
|
+
vec: new Float32Array(blobToVec(r.embedding)),
|
|
16
|
+
}));
|
|
17
|
+
_cacheChunkCount = count;
|
|
18
|
+
return _cache;
|
|
19
|
+
}
|
|
9
20
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
21
|
+
function cosineSim(a, b) {
|
|
22
|
+
let dot = 0, na = 0, nb = 0;
|
|
23
|
+
for (let i = 0; i < a.length; i++) {
|
|
24
|
+
dot += a[i] * b[i];
|
|
25
|
+
na += a[i] * a[i];
|
|
26
|
+
nb += b[i] * b[i];
|
|
15
27
|
}
|
|
16
|
-
return
|
|
28
|
+
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
|
|
17
29
|
}
|
|
18
30
|
|
|
31
|
+
// Called after reindex — invalidate cache so next search reloads
|
|
19
32
|
export async function buildAnnIndex() {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
33
|
+
_cache = null;
|
|
34
|
+
_cacheChunkCount = -1;
|
|
23
35
|
const db = getDb();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
// Batch ALL inserts into a single disk write — vectra otherwise
|
|
27
|
-
// persists the whole index on every insertItem (O(N^2) I/O).
|
|
28
|
-
await index.beginUpdate();
|
|
29
|
-
try {
|
|
30
|
-
for (const chunk of chunks) {
|
|
31
|
-
const vec = JSON.parse(chunk.embedding);
|
|
32
|
-
await index.insertItem({
|
|
33
|
-
vector: vec,
|
|
34
|
-
metadata: { skill_id: chunk.skill_id, chunk_index: chunk.chunk_index },
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
await index.endUpdate();
|
|
38
|
-
} catch (e) {
|
|
39
|
-
try { index.cancelUpdate(); } catch {}
|
|
40
|
-
throw e;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
_index = null; // force reload so queries see fresh index
|
|
44
|
-
console.error(`[PromptGraph] ANN index built: ${chunks.length} chunks`);
|
|
36
|
+
const count = db.prepare('SELECT COUNT(*) as n FROM chunks').get().n;
|
|
37
|
+
console.error(`[PromptGraph] In-memory index ready: ${count} chunks`);
|
|
45
38
|
}
|
|
46
39
|
|
|
47
40
|
export async function annSearch(queryVec, topK = 20) {
|
|
48
41
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
42
|
+
const db = getDb();
|
|
43
|
+
const cache = loadCache(db);
|
|
44
|
+
if (!cache.length) return null;
|
|
45
|
+
|
|
46
|
+
const qArr = new Float32Array(queryVec);
|
|
47
|
+
const bestBySkill = new Map();
|
|
48
|
+
for (const entry of cache) {
|
|
49
|
+
const score = cosineSim(qArr, entry.vec);
|
|
50
|
+
const prev = bestBySkill.get(entry.skill_id);
|
|
51
|
+
if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
|
|
52
|
+
}
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
score
|
|
58
|
-
}));
|
|
54
|
+
return [...bestBySkill.entries()]
|
|
55
|
+
.sort((a, b) => b[1] - a[1])
|
|
56
|
+
.slice(0, topK)
|
|
57
|
+
.map(([skill_id, score]) => ({ skill_id, score }));
|
|
59
58
|
} catch {
|
|
60
59
|
return null;
|
|
61
60
|
}
|
package/chunker.js
CHANGED
|
@@ -2,15 +2,24 @@ const CHUNK_SIZE = 800;
|
|
|
2
2
|
const CHUNK_OVERLAP = 100;
|
|
3
3
|
|
|
4
4
|
export function chunkText(text) {
|
|
5
|
-
|
|
5
|
+
// Split on markdown h1/h2/h3 headers to preserve semantic boundaries
|
|
6
|
+
const sections = text.split(/(?=\n#{1,3} )/);
|
|
6
7
|
const chunks = [];
|
|
7
|
-
let i = 0;
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
for (const section of sections) {
|
|
10
|
+
const words = section.split(/\s+/).filter(Boolean);
|
|
11
|
+
if (words.length === 0) continue;
|
|
12
|
+
|
|
13
|
+
if (words.length <= CHUNK_SIZE) {
|
|
14
|
+
chunks.push(section.trim());
|
|
15
|
+
} else {
|
|
16
|
+
let i = 0;
|
|
17
|
+
while (i < words.length) {
|
|
18
|
+
chunks.push(words.slice(i, i + CHUNK_SIZE).join(' '));
|
|
19
|
+
if (i + CHUNK_SIZE >= words.length) break;
|
|
20
|
+
i += CHUNK_SIZE - CHUNK_OVERLAP;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
14
23
|
}
|
|
15
24
|
|
|
16
25
|
return chunks.length > 0 ? chunks : [text];
|
package/config.js
CHANGED
|
@@ -3,7 +3,9 @@ import path from 'path';
|
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import readline from 'readline';
|
|
5
5
|
|
|
6
|
-
const
|
|
6
|
+
export const PROMPTGRAPH_DIR = path.join(os.homedir(), '.claude', '.promptgraph');
|
|
7
|
+
export const SKILLS_STORE_DIR = path.join(os.homedir(), '.claude', 'skills-store');
|
|
8
|
+
const CONFIG_PATH = path.join(PROMPTGRAPH_DIR, 'config.json');
|
|
7
9
|
|
|
8
10
|
const DEFAULTS = {
|
|
9
11
|
sources: [
|
package/db.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import Database from 'better-sqlite3';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
3
|
import fs from 'fs';
|
|
4
|
+
import { PROMPTGRAPH_DIR } from './config.js';
|
|
5
5
|
|
|
6
|
-
const DB_PATH = path.join(
|
|
6
|
+
const DB_PATH = path.join(PROMPTGRAPH_DIR, 'promptgraph.db');
|
|
7
7
|
|
|
8
8
|
let _db = null;
|
|
9
9
|
|
|
@@ -30,7 +30,7 @@ export function getDb() {
|
|
|
30
30
|
skill_id TEXT NOT NULL,
|
|
31
31
|
chunk_index INTEGER NOT NULL,
|
|
32
32
|
text TEXT NOT NULL,
|
|
33
|
-
embedding
|
|
33
|
+
embedding BLOB NOT NULL,
|
|
34
34
|
UNIQUE(skill_id, chunk_index)
|
|
35
35
|
);
|
|
36
36
|
|
|
@@ -54,9 +54,33 @@ export function getDb() {
|
|
|
54
54
|
db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// migrate: convert JSON text embeddings to Float32 BLOB (one-time, ~10x smaller)
|
|
58
|
+
const textEmbeddings = db.prepare("SELECT COUNT(*) as n FROM chunks WHERE typeof(embedding) = 'text'").get();
|
|
59
|
+
if (textEmbeddings?.n > 0) {
|
|
60
|
+
const rows = db.prepare("SELECT rowid, embedding FROM chunks WHERE typeof(embedding) = 'text'").all();
|
|
61
|
+
const upd = db.prepare('UPDATE chunks SET embedding = ? WHERE rowid = ?');
|
|
62
|
+
db.transaction(() => {
|
|
63
|
+
for (const row of rows) {
|
|
64
|
+
const vec = JSON.parse(row.embedding);
|
|
65
|
+
upd.run(Buffer.from(new Float32Array(vec).buffer), row.rowid);
|
|
66
|
+
}
|
|
67
|
+
})();
|
|
68
|
+
console.error(`[PromptGraph] Migrated ${textEmbeddings.n} embeddings TEXT→BLOB`);
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
return db;
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
export function skillId(source, name) {
|
|
61
75
|
return `${source}::${name}`;
|
|
62
76
|
}
|
|
77
|
+
|
|
78
|
+
export function vecToBlob(vec) {
|
|
79
|
+
return Buffer.from(new Float32Array(vec).buffer);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function blobToVec(blob) {
|
|
83
|
+
if (typeof blob === 'string') return JSON.parse(blob);
|
|
84
|
+
const buf = Buffer.isBuffer(blob) ? blob : Buffer.from(blob);
|
|
85
|
+
return Array.from(new Float32Array(buf.buffer, buf.byteOffset, buf.length / 4));
|
|
86
|
+
}
|
package/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const args = process.argv.slice(2);
|
|
|
12
12
|
const rawBin = process.argv[1]?.split(/[\\/]/).pop()?.replace(/\.js$/, '');
|
|
13
13
|
const bin = (rawBin && rawBin !== 'index') ? rawBin : 'pg';
|
|
14
14
|
|
|
15
|
-
const KNOWN_COMMANDS = new Set(['init', 'reindex', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'help', '--help', '-h']);
|
|
15
|
+
const KNOWN_COMMANDS = new Set(['init', 'reindex', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h']);
|
|
16
16
|
|
|
17
17
|
function showHelp() {
|
|
18
18
|
console.log(
|
|
@@ -26,6 +26,7 @@ function showHelp() {
|
|
|
26
26
|
const cmds = [
|
|
27
27
|
['init', 'First-time setup + index all skills'],
|
|
28
28
|
['reindex', 'Re-index all skills'],
|
|
29
|
+
['search <query>', 'Search skills from the terminal'],
|
|
29
30
|
['import <owner/repo>', 'Import skills from GitHub'],
|
|
30
31
|
['marketplace [page]', 'Browse the community skill registry'],
|
|
31
32
|
['validate <file.md>', 'Validate a skill before publishing'],
|
|
@@ -36,7 +37,7 @@ function showHelp() {
|
|
|
36
37
|
for (const [cmd, desc] of cmds) {
|
|
37
38
|
console.log(' ' + chalk.hex('#7C3AED')((bin + ' ' + cmd).padEnd(28)) + chalk.gray(desc));
|
|
38
39
|
}
|
|
39
|
-
console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf'));
|
|
40
|
+
console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf, opencode'));
|
|
40
41
|
console.log(chalk.gray('\n github.com/NeiP4n/promptgraph · npmjs.com/package/promptgraph-mcp\n'));
|
|
41
42
|
}
|
|
42
43
|
|
|
@@ -115,8 +116,14 @@ if (args[0] === 'marketplace' && (args[1] === 'bundles' || args[1] === 'bundle')
|
|
|
115
116
|
console.log();
|
|
116
117
|
});
|
|
117
118
|
|
|
118
|
-
console.log(
|
|
119
|
-
|
|
119
|
+
console.log(
|
|
120
|
+
boxen(
|
|
121
|
+
chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
|
|
122
|
+
chalk.dim('browse skills ') + chalk.cyan(`${bin} marketplace`) + '\n' +
|
|
123
|
+
chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>'),
|
|
124
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
|
|
125
|
+
)
|
|
126
|
+
);
|
|
120
127
|
console.log();
|
|
121
128
|
process.exit(0);
|
|
122
129
|
}
|
|
@@ -189,10 +196,21 @@ if (args[0] === 'marketplace') {
|
|
|
189
196
|
if (page > 1) nav.push(chalk.dim('‹ ') + chalk.cyan(`${bin} marketplace ${page - 1}`));
|
|
190
197
|
if (page < totalPages) nav.push(chalk.cyan(`${bin} marketplace ${page + 1}`) + chalk.dim(' ›'));
|
|
191
198
|
console.log(' ' + nav.join(' '));
|
|
199
|
+
console.log();
|
|
192
200
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
console.log(
|
|
201
|
+
|
|
202
|
+
const exCode = slice[0]?.code || slice[0]?.id || 'pg-xxxxxx';
|
|
203
|
+
console.log(
|
|
204
|
+
boxen(
|
|
205
|
+
chalk.dim('install skill ') + chalk.white('install ') + chalk.hex('#A78BFA')(exCode) + '\n' +
|
|
206
|
+
chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
|
|
207
|
+
chalk.dim('from GitHub ') + chalk.white('install ') + chalk.hex('#A78BFA')('https://github.com/owner/repo/blob/main/skill.md') + '\n' +
|
|
208
|
+
chalk.dim('publish skill ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<file.md>') + '\n' +
|
|
209
|
+
chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>') + '\n' +
|
|
210
|
+
chalk.dim('view bundles ') + chalk.cyan(`${bin} marketplace bundles`),
|
|
211
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
|
|
212
|
+
)
|
|
213
|
+
);
|
|
196
214
|
console.log();
|
|
197
215
|
process.exit(0);
|
|
198
216
|
}
|
|
@@ -213,6 +231,27 @@ if (args[0] === 'validate') {
|
|
|
213
231
|
}
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
if (args[0] === 'search') {
|
|
235
|
+
const query = args.slice(1).join(' ');
|
|
236
|
+
if (!query) { error('Usage: ' + bin + ' search <query>'); process.exit(1); }
|
|
237
|
+
const { search: searchSkills } = await import('./search.js');
|
|
238
|
+
const spin = (await import('./cli.js')).spinner('Searching...');
|
|
239
|
+
spin.start();
|
|
240
|
+
const results = await searchSkills(query, 10);
|
|
241
|
+
spin.stop();
|
|
242
|
+
if (!results.length) { info('No results for: ' + query); process.exit(0); }
|
|
243
|
+
const purple = chalk.hex('#7C3AED');
|
|
244
|
+
console.log();
|
|
245
|
+
results.forEach((s, i) => {
|
|
246
|
+
const score = chalk.dim((s.score * 100).toFixed(0) + '%');
|
|
247
|
+
console.log(' ' + chalk.dim(String(i + 1) + '.') + ' ' + chalk.bold.white(s.name) + ' ' + score);
|
|
248
|
+
console.log(' ' + chalk.dim(s.description || ''));
|
|
249
|
+
console.log(' ' + purple(s.source) + ' ' + chalk.dim(s.path));
|
|
250
|
+
console.log();
|
|
251
|
+
});
|
|
252
|
+
process.exit(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
216
255
|
if (args[0] === 'import') {
|
|
217
256
|
const { importFromGitHub } = await import('./github-import.js');
|
|
218
257
|
await importFromGitHub(args[1]);
|
|
@@ -239,6 +278,24 @@ if (args[0] === 'setup') {
|
|
|
239
278
|
if (args[0] === 'init') {
|
|
240
279
|
const { promptConfig } = await import('./config.js');
|
|
241
280
|
const { indexAll } = await import('./indexer.js');
|
|
281
|
+
const os = await import('os');
|
|
282
|
+
const fs = await import('fs');
|
|
283
|
+
const path = await import('path');
|
|
284
|
+
const commandsDir = path.default.join(os.default.homedir(), '.claude', 'commands');
|
|
285
|
+
if (!fs.default.existsSync(commandsDir)) {
|
|
286
|
+
console.log(chalk.yellow('⚠') + ' ' + chalk.gray('~/.claude/commands/ not found — is Claude Code installed?'));
|
|
287
|
+
console.log(chalk.gray(' Install from: https://claude.ai/download\n'));
|
|
288
|
+
}
|
|
289
|
+
if (!args.includes('--yes') && !args.includes('-y')) {
|
|
290
|
+
const readline = await import('readline');
|
|
291
|
+
const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
292
|
+
const answer = await new Promise(r => rl.question(
|
|
293
|
+
chalk.yellow(' ⚠') + chalk.gray(' First run downloads ~23 MB embedding model (BGE-Small-EN).\n Proceed? [Y/n] '), r
|
|
294
|
+
));
|
|
295
|
+
rl.close();
|
|
296
|
+
if (answer.trim().toLowerCase() === 'n') { info('Aborted.'); process.exit(0); }
|
|
297
|
+
}
|
|
298
|
+
console.log(chalk.gray('\n Downloading embedding model (~23 MB, one-time)...\n'));
|
|
242
299
|
const config = await promptConfig();
|
|
243
300
|
await indexAll();
|
|
244
301
|
console.log();
|
|
@@ -263,8 +320,9 @@ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
|
|
|
263
320
|
const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
264
321
|
const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
|
|
265
322
|
const { search, getContext, getCallers, getCallees, getImpact, listAll } = await import('./search.js');
|
|
323
|
+
const { loadConfig: _loadConfig, saveConfig: _saveConfig } = await import('./config.js');
|
|
266
324
|
const { startWatcher } = await import('./watcher.js');
|
|
267
|
-
const { browseMarketplace, installSkill, publishSkill, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
|
|
325
|
+
const { browseMarketplace, installSkill, installSkillFromUrl, publishSkill, publishBundle, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
|
|
268
326
|
|
|
269
327
|
const server = new Server(
|
|
270
328
|
{ name: 'promptgraph', version: '1.0.0' },
|
|
@@ -386,6 +444,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
|
386
444
|
required: ['bundle_id'],
|
|
387
445
|
},
|
|
388
446
|
},
|
|
447
|
+
{
|
|
448
|
+
name: 'pg_config',
|
|
449
|
+
description: 'Get or update PromptGraph config. action="get" returns current sources. action="add_source" adds a directory.',
|
|
450
|
+
inputSchema: {
|
|
451
|
+
type: 'object',
|
|
452
|
+
properties: {
|
|
453
|
+
action: { type: 'string', enum: ['get', 'add_source', 'remove_source'] },
|
|
454
|
+
dir: { type: 'string', description: 'Directory path (for add_source)' },
|
|
455
|
+
source: { type: 'string', description: 'Source label (for add_source/remove_source)' },
|
|
456
|
+
},
|
|
457
|
+
required: ['action'],
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: 'pg_install_url',
|
|
462
|
+
description: 'Install a skill directly from a GitHub URL or raw URL. Validates before saving.',
|
|
463
|
+
inputSchema: {
|
|
464
|
+
type: 'object',
|
|
465
|
+
properties: { url: { type: 'string', description: 'GitHub blob URL or raw URL of a .md skill file' } },
|
|
466
|
+
required: ['url'],
|
|
467
|
+
},
|
|
468
|
+
},
|
|
469
|
+
{
|
|
470
|
+
name: 'pg_bundle_publish',
|
|
471
|
+
description: 'Publish a bundle definition to GitHub Gist and get a registry submission link. Pass a JSON object or path to a .json file.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
type: 'object',
|
|
474
|
+
properties: {
|
|
475
|
+
bundle: {
|
|
476
|
+
description: 'Bundle definition object { id, name, description, skills[], tags[] } OR file path to .json',
|
|
477
|
+
oneOf: [
|
|
478
|
+
{ type: 'object' },
|
|
479
|
+
{ type: 'string' },
|
|
480
|
+
],
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
required: ['bundle'],
|
|
484
|
+
},
|
|
485
|
+
},
|
|
389
486
|
],
|
|
390
487
|
}));
|
|
391
488
|
|
|
@@ -413,6 +510,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
413
510
|
case 'pg_marketplace_publish': result = await publishSkill(args.file_path); break;
|
|
414
511
|
case 'pg_bundle_browse': result = await browseBundles(args.top_k || 20); break;
|
|
415
512
|
case 'pg_bundle_install': result = await installBundle(args.bundle_id); break;
|
|
513
|
+
case 'pg_install_url': result = await installSkillFromUrl(args.url); break;
|
|
514
|
+
case 'pg_bundle_publish': result = await publishBundle(args.bundle); break;
|
|
515
|
+
case 'pg_config': {
|
|
516
|
+
const cfg = _loadConfig();
|
|
517
|
+
if (args.action === 'get') {
|
|
518
|
+
result = { sources: cfg.sources };
|
|
519
|
+
} else if (args.action === 'add_source') {
|
|
520
|
+
if (!args.dir || !args.source) throw new Error('dir and source required for add_source');
|
|
521
|
+
if (cfg.sources.find(s => s.source === args.source)) throw new Error(`Source "${args.source}" already exists`);
|
|
522
|
+
cfg.sources.push({ dir: args.dir, source: args.source });
|
|
523
|
+
_saveConfig(cfg);
|
|
524
|
+
result = { ok: true, sources: cfg.sources };
|
|
525
|
+
} else if (args.action === 'remove_source') {
|
|
526
|
+
if (!args.source) throw new Error('source required for remove_source');
|
|
527
|
+
const before = cfg.sources.length;
|
|
528
|
+
cfg.sources = cfg.sources.filter(s => s.source !== args.source);
|
|
529
|
+
if (cfg.sources.length === before) throw new Error(`Source "${args.source}" not found`);
|
|
530
|
+
_saveConfig(cfg);
|
|
531
|
+
result = { ok: true, sources: cfg.sources };
|
|
532
|
+
} else {
|
|
533
|
+
throw new Error(`Unknown action: ${args.action}`);
|
|
534
|
+
}
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
416
537
|
default: throw new Error(`Unknown tool: ${name}`);
|
|
417
538
|
}
|
|
418
539
|
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
@@ -426,3 +547,11 @@ startWatcher();
|
|
|
426
547
|
const transport = new StdioServerTransport();
|
|
427
548
|
await server.connect(transport);
|
|
428
549
|
console.error('[PromptGraph] MCP server running');
|
|
550
|
+
|
|
551
|
+
const { getDb: _getDb } = await import('./db.js');
|
|
552
|
+
const shutdown = () => {
|
|
553
|
+
try { _getDb().close(); } catch {}
|
|
554
|
+
process.exit(0);
|
|
555
|
+
};
|
|
556
|
+
process.on('SIGTERM', shutdown);
|
|
557
|
+
process.on('SIGINT', shutdown);
|
package/indexer.js
CHANGED
|
@@ -4,7 +4,7 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { parseSkillFile, isSkillFile } from './parser.js';
|
|
6
6
|
import { embedBatch, BATCH_SIZE } from './embedder.js';
|
|
7
|
-
import { getDb, skillId } from './db.js';
|
|
7
|
+
import { getDb, skillId, vecToBlob } from './db.js';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
import { chunkText } from './chunker.js';
|
|
10
10
|
import { buildAnnIndex } from './ann.js';
|
|
@@ -54,7 +54,7 @@ async function indexBatch(db, skills) {
|
|
|
54
54
|
}
|
|
55
55
|
for (let i = 0; i < allChunks.length; i++) {
|
|
56
56
|
const { id, chunkIndex, text } = allChunks[i];
|
|
57
|
-
upsertChunk.run(id, chunkIndex, text,
|
|
57
|
+
upsertChunk.run(id, chunkIndex, text, vecToBlob(embeddings[i]));
|
|
58
58
|
}
|
|
59
59
|
})();
|
|
60
60
|
|
|
@@ -161,9 +161,9 @@ export async function indexAll() {
|
|
|
161
161
|
const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
|
|
162
162
|
progress(count, total, { skipped, eta, errors });
|
|
163
163
|
}
|
|
164
|
-
} catch {
|
|
164
|
+
} catch (e) {
|
|
165
165
|
errors++;
|
|
166
|
-
|
|
166
|
+
console.error(`[PromptGraph] Error indexing ${file}: ${e.message}`);
|
|
167
167
|
try {
|
|
168
168
|
const stale = db.prepare('SELECT id FROM skills WHERE path = ?').get(file);
|
|
169
169
|
if (stale) {
|
|
@@ -181,9 +181,6 @@ export async function indexAll() {
|
|
|
181
181
|
count += batch.length;
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// rebuild all edges for unchanged skills too (fixes edge loss bug)
|
|
185
|
-
rebuildEdgesForUnchanged(db);
|
|
186
|
-
|
|
187
184
|
progress(total, total, { skipped, errors });
|
|
188
185
|
progressDone();
|
|
189
186
|
const spin = spinner('Building ANN index...');
|
|
@@ -195,13 +192,6 @@ export async function indexAll() {
|
|
|
195
192
|
info(chalk.gray(`Time: ${elapsed}s`));
|
|
196
193
|
}
|
|
197
194
|
|
|
198
|
-
function rebuildEdgesForUnchanged(db) {
|
|
199
|
-
// For skills that were skipped (hash unchanged), their edges were not touched.
|
|
200
|
-
// This is correct — we only delete+rebuild edges for skills that were re-indexed.
|
|
201
|
-
// No action needed here: edges for unchanged skills remain intact.
|
|
202
|
-
// The global DELETE FROM edges at the start was the bug — it's now removed.
|
|
203
|
-
}
|
|
204
|
-
|
|
205
195
|
export async function indexFile(filePath, source) {
|
|
206
196
|
const db = getDb();
|
|
207
197
|
const skill = parseSkillFile(filePath, source);
|
package/marketplace.js
CHANGED
|
@@ -1,15 +1,43 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import os from 'os';
|
|
4
3
|
import https from 'https';
|
|
5
4
|
import { createHash } from 'crypto';
|
|
6
5
|
import { spawnSync } from 'child_process';
|
|
6
|
+
import { createRequire } from 'module';
|
|
7
7
|
import { getDb } from './db.js';
|
|
8
|
-
import { validateSkill } from './validator.js';
|
|
9
|
-
import { loadConfig, saveConfig } from './config.js';
|
|
8
|
+
import { validateSkill, validateBundle } from './validator.js';
|
|
9
|
+
import { loadConfig, saveConfig, PROMPTGRAPH_DIR, SKILLS_STORE_DIR } from './config.js';
|
|
10
10
|
|
|
11
11
|
const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
|
|
12
|
-
const SKILLS_DIR = path.join(
|
|
12
|
+
const SKILLS_DIR = path.join(SKILLS_STORE_DIR, 'marketplace');
|
|
13
|
+
|
|
14
|
+
// Atomically write content to dest via tmp — cleans up on failure
|
|
15
|
+
function writeSkillAtomic(dest, content) {
|
|
16
|
+
const tmpPath = dest + '.tmp';
|
|
17
|
+
try {
|
|
18
|
+
fs.writeFileSync(tmpPath, content);
|
|
19
|
+
const v = validateSkill(tmpPath);
|
|
20
|
+
if (!v.ok) { fs.unlinkSync(tmpPath); return v; }
|
|
21
|
+
fs.renameSync(tmpPath, dest);
|
|
22
|
+
return { ok: true };
|
|
23
|
+
} catch (e) {
|
|
24
|
+
try { fs.unlinkSync(tmpPath); } catch {}
|
|
25
|
+
return { ok: false, errors: [e.message] };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Convert GitHub blob URL → raw URL
|
|
30
|
+
// https://github.com/owner/repo/blob/branch/path/file.md
|
|
31
|
+
// → https://raw.githubusercontent.com/owner/repo/branch/path/file.md
|
|
32
|
+
function githubToRaw(url) {
|
|
33
|
+
const m = url.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+)\/blob\/(.+)$/);
|
|
34
|
+
if (m) return `https://raw.githubusercontent.com/${m[1]}/${m[2]}`;
|
|
35
|
+
return null; // already raw or not a github URL
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const _require = createRequire(import.meta.url);
|
|
39
|
+
const PKG_VERSION = (() => { try { return _require('./package.json').version; } catch { return '1.x'; } })();
|
|
40
|
+
const UA = `promptgraph-mcp/${PKG_VERSION}`;
|
|
13
41
|
|
|
14
42
|
// Deterministic short code from an id. Same id always yields the same code,
|
|
15
43
|
// so codes auto-generate — no need to assign them by hand.
|
|
@@ -20,7 +48,7 @@ export function codeFor(id) {
|
|
|
20
48
|
// node:https GET — reliable and fast on Windows (undici fetch can hang ~10s there).
|
|
21
49
|
function httpGet(url) {
|
|
22
50
|
return new Promise((resolve, reject) => {
|
|
23
|
-
const req = https.get(url, { headers: { 'User-Agent':
|
|
51
|
+
const req = https.get(url, { headers: { 'User-Agent': UA }, timeout: 8000, family: 4 }, (res) => {
|
|
24
52
|
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
25
53
|
return httpGet(res.headers.location).then(resolve, reject);
|
|
26
54
|
}
|
|
@@ -43,18 +71,17 @@ async function rawFetch(url) {
|
|
|
43
71
|
try {
|
|
44
72
|
return await httpGet(url);
|
|
45
73
|
} catch {
|
|
46
|
-
const res = await fetch(url, { headers: { 'User-Agent':
|
|
74
|
+
const res = await fetch(url, { headers: { 'User-Agent': UA } });
|
|
47
75
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
48
76
|
return await res.text();
|
|
49
77
|
}
|
|
50
78
|
}
|
|
51
79
|
|
|
52
80
|
// Disk cache for the registry (network to GitHub raw can be slow on some networks).
|
|
53
|
-
const CACHE_DIR = path.join(os.homedir(), '.claude', '.promptgraph');
|
|
54
81
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
55
82
|
|
|
56
83
|
async function fetchText(url) {
|
|
57
|
-
const cacheFile = path.join(
|
|
84
|
+
const cacheFile = path.join(PROMPTGRAPH_DIR, 'registry-cache.json');
|
|
58
85
|
const isRegistry = url === REGISTRY_URL;
|
|
59
86
|
|
|
60
87
|
if (isRegistry && fs.existsSync(cacheFile)) {
|
|
@@ -70,7 +97,7 @@ async function fetchText(url) {
|
|
|
70
97
|
|
|
71
98
|
if (isRegistry) {
|
|
72
99
|
try {
|
|
73
|
-
fs.mkdirSync(
|
|
100
|
+
fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
|
|
74
101
|
fs.writeFileSync(cacheFile, text);
|
|
75
102
|
} catch {}
|
|
76
103
|
}
|
|
@@ -91,8 +118,31 @@ export async function browseMarketplace(topK = 20) {
|
|
|
91
118
|
}
|
|
92
119
|
}
|
|
93
120
|
|
|
121
|
+
export async function installSkillFromUrl(url) {
|
|
122
|
+
try {
|
|
123
|
+
const rawUrl = githubToRaw(url) || url;
|
|
124
|
+
const content = await fetchText(rawUrl);
|
|
125
|
+
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
|
126
|
+
ensureMarketplaceSource();
|
|
127
|
+
|
|
128
|
+
// derive filename from URL
|
|
129
|
+
const urlName = rawUrl.split('/').pop().replace(/[^a-z0-9-_.]/gi, '-');
|
|
130
|
+
const dest = path.join(SKILLS_DIR, urlName.endsWith('.md') ? urlName : urlName + '.md');
|
|
131
|
+
const v = writeSkillAtomic(dest, content);
|
|
132
|
+
if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
|
|
133
|
+
return { success: true, path: dest, url: rawUrl };
|
|
134
|
+
} catch (e) {
|
|
135
|
+
return { error: e.message };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
94
139
|
export async function installSkill(query) {
|
|
95
140
|
try {
|
|
141
|
+
// GitHub URL or raw URL → direct install
|
|
142
|
+
if (query.startsWith('http://') || query.startsWith('https://')) {
|
|
143
|
+
return installSkillFromUrl(query);
|
|
144
|
+
}
|
|
145
|
+
|
|
96
146
|
const text = await fetchText(REGISTRY_URL);
|
|
97
147
|
const registry = JSON.parse(text);
|
|
98
148
|
const q = String(query).trim().toLowerCase();
|
|
@@ -107,7 +157,7 @@ export async function installSkill(query) {
|
|
|
107
157
|
(b.code || codeFor(b.id)).toLowerCase() === q || b.id?.toLowerCase() === q
|
|
108
158
|
);
|
|
109
159
|
if (bundle) return { error: `"${query}" is a bundle. Use pg_bundle_install("${bundle.id}") instead.` };
|
|
110
|
-
return { error: `No skill matching "${query}" (try a code
|
|
160
|
+
return { error: `No skill matching "${query}" (try a code, id, name, or GitHub URL)` };
|
|
111
161
|
}
|
|
112
162
|
if (!skill.raw_url) return { error: `Skill "${skill.id}" has no download URL` };
|
|
113
163
|
const skillId = skill.id;
|
|
@@ -117,17 +167,8 @@ export async function installSkill(query) {
|
|
|
117
167
|
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
|
|
118
168
|
|
|
119
169
|
const content = await fetchText(skill.raw_url);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const tmpPath = dest + '.tmp';
|
|
123
|
-
fs.writeFileSync(tmpPath, content);
|
|
124
|
-
const validation = validateSkill(tmpPath);
|
|
125
|
-
if (!validation.ok) {
|
|
126
|
-
fs.unlinkSync(tmpPath);
|
|
127
|
-
return { error: 'Downloaded skill failed validation', issues: validation.errors };
|
|
128
|
-
}
|
|
129
|
-
fs.renameSync(tmpPath, dest);
|
|
130
|
-
|
|
170
|
+
const v = writeSkillAtomic(dest, content);
|
|
171
|
+
if (!v.ok) return { error: 'Downloaded skill failed validation', issues: v.errors };
|
|
131
172
|
return { success: true, path: dest, name: skill.name };
|
|
132
173
|
} catch (e) {
|
|
133
174
|
return { error: e.message };
|
|
@@ -173,17 +214,16 @@ export async function installBundle(bundleId) {
|
|
|
173
214
|
const installed = [];
|
|
174
215
|
const failed = [];
|
|
175
216
|
|
|
217
|
+
const delay = (ms) => new Promise(r => setTimeout(r, ms));
|
|
176
218
|
for (const skillId of bundle.skills || []) {
|
|
177
219
|
const skill = registry.skills?.find(s => s.id === skillId);
|
|
178
220
|
if (!skill?.raw_url) { failed.push(skillId); continue; }
|
|
179
221
|
try {
|
|
222
|
+
if (installed.length > 0) await delay(300); // rate limit: 300ms between requests
|
|
180
223
|
const content = await fetchText(skill.raw_url);
|
|
181
224
|
const dest = path.join(SKILLS_DIR, `${skillId}.md`);
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
const validation = validateSkill(tmpPath);
|
|
185
|
-
if (!validation.ok) { fs.unlinkSync(tmpPath); failed.push(skillId); continue; }
|
|
186
|
-
fs.renameSync(tmpPath, dest);
|
|
225
|
+
const v = writeSkillAtomic(dest, content);
|
|
226
|
+
if (!v.ok) { failed.push(skillId); continue; }
|
|
187
227
|
installed.push(skillId);
|
|
188
228
|
} catch {
|
|
189
229
|
failed.push(skillId);
|
|
@@ -196,34 +236,88 @@ export async function installBundle(bundleId) {
|
|
|
196
236
|
}
|
|
197
237
|
}
|
|
198
238
|
|
|
239
|
+
function ghPublish(filePath, desc) {
|
|
240
|
+
try {
|
|
241
|
+
const result = spawnSync('gh', ['gist', 'create', filePath, '--desc', desc, '--public'], { encoding: 'utf8' });
|
|
242
|
+
if (result.error?.code === 'ENOENT') return { ok: false, no_gh: true };
|
|
243
|
+
if (result.status !== 0) return { ok: false, error: result.stderr?.trim() || 'gh CLI error — run: gh auth login' };
|
|
244
|
+
return { ok: true, url: result.stdout.trim() };
|
|
245
|
+
} catch {
|
|
246
|
+
return { ok: false, no_gh: true };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const REGISTRY_ISSUES = 'https://github.com/NeiP4n/promptgraph-registry/issues/new';
|
|
251
|
+
|
|
199
252
|
export async function publishSkill(filePath) {
|
|
200
253
|
if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
|
|
201
254
|
|
|
202
|
-
// validate before publishing — block junk and malicious skills
|
|
203
255
|
const validation = validateSkill(filePath);
|
|
204
256
|
if (!validation.ok) {
|
|
205
257
|
return { error: 'Validation failed', issues: validation.errors, warnings: validation.warnings };
|
|
206
258
|
}
|
|
207
259
|
|
|
208
260
|
const name = path.basename(filePath, '.md');
|
|
261
|
+
const gh = ghPublish(filePath, `PromptGraph skill: ${name}`);
|
|
209
262
|
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
'gh',
|
|
213
|
-
['gist', 'create', filePath, '--desc', `PromptGraph skill: ${name}`, '--public'],
|
|
214
|
-
{ encoding: 'utf8' }
|
|
215
|
-
);
|
|
216
|
-
if (result.status !== 0) {
|
|
217
|
-
return { error: result.stderr?.trim() || 'gh CLI error. Run: gh auth login' };
|
|
218
|
-
}
|
|
263
|
+
if (gh.no_gh) {
|
|
264
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
219
265
|
return {
|
|
220
266
|
success: true,
|
|
221
|
-
|
|
222
|
-
|
|
267
|
+
gh_not_installed: true,
|
|
268
|
+
instructions: [
|
|
269
|
+
'1. Install gh CLI: https://cli.github.com',
|
|
270
|
+
' OR manually create a public Gist at https://gist.github.com with the file content',
|
|
271
|
+
`2. Submit to registry: ${REGISTRY_ISSUES}`,
|
|
272
|
+
`3. Paste the Gist URL in the issue`,
|
|
273
|
+
].join('\n'),
|
|
274
|
+
file_content: content,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
if (!gh.ok) return { error: gh.error };
|
|
278
|
+
return { success: true, url: gh.url, message: `Published! Submit to registry: ${REGISTRY_ISSUES}` };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function publishBundle(bundleDef) {
|
|
282
|
+
// bundleDef: { id, name, description, skills: [...], tags: [...] }
|
|
283
|
+
// OR a path to a .json file
|
|
284
|
+
let def = bundleDef;
|
|
285
|
+
if (typeof bundleDef === 'string') {
|
|
286
|
+
if (!fs.existsSync(bundleDef)) return { error: `File not found: ${bundleDef}` };
|
|
287
|
+
try { def = JSON.parse(fs.readFileSync(bundleDef, 'utf8')); }
|
|
288
|
+
catch (e) { return { error: `Invalid JSON: ${e.message}` }; }
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const validation = validateBundle(def);
|
|
292
|
+
if (!validation.ok) {
|
|
293
|
+
return { error: 'Bundle validation failed', issues: validation.errors, warnings: validation.warnings };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const bundleJson = JSON.stringify(def, null, 2);
|
|
297
|
+
const tmpFile = path.join(PROMPTGRAPH_DIR, `bundle-${def.id}.json`);
|
|
298
|
+
fs.mkdirSync(PROMPTGRAPH_DIR, { recursive: true });
|
|
299
|
+
fs.writeFileSync(tmpFile, bundleJson);
|
|
300
|
+
|
|
301
|
+
const gh = ghPublish(tmpFile, `PromptGraph bundle: ${def.name}`);
|
|
302
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
303
|
+
|
|
304
|
+
if (gh.no_gh) {
|
|
305
|
+
const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=${encodeURIComponent('Bundle definition:\n\n```json\n' + bundleJson + '\n```')}`;
|
|
306
|
+
return {
|
|
307
|
+
success: true,
|
|
308
|
+
gh_not_installed: true,
|
|
309
|
+
instructions: [
|
|
310
|
+
'1. Install gh CLI: https://cli.github.com',
|
|
311
|
+
` OR open this pre-filled issue directly: ${issueUrl}`,
|
|
312
|
+
'2. Paste the bundle JSON shown below into the issue body',
|
|
313
|
+
].join('\n'),
|
|
314
|
+
bundle_json: bundleJson,
|
|
315
|
+
submit_url: issueUrl,
|
|
223
316
|
};
|
|
224
|
-
} catch {
|
|
225
|
-
return { error: 'gh CLI not found. Install from: https://cli.github.com' };
|
|
226
317
|
}
|
|
318
|
+
if (!gh.ok) return { error: gh.error };
|
|
319
|
+
const issueUrl = `${REGISTRY_ISSUES}?title=Bundle%3A+${encodeURIComponent(def.name)}&body=Gist%3A+${encodeURIComponent(gh.url)}`;
|
|
320
|
+
return { success: true, gist_url: gh.url, submit_url: issueUrl, message: `Bundle published! Submit: ${issueUrl}` };
|
|
227
321
|
}
|
|
228
322
|
|
|
229
323
|
export function getTopRated(topK = 10) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "promptgraph-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,13 +42,12 @@
|
|
|
42
42
|
"fastembed": "^2.1.0",
|
|
43
43
|
"glob": "^13.0.6",
|
|
44
44
|
"gray-matter": "^4.0.3",
|
|
45
|
-
"ora": "^9.4.0"
|
|
46
|
-
"vectra": "^0.15.0"
|
|
45
|
+
"ora": "^9.4.0"
|
|
47
46
|
},
|
|
48
47
|
"devDependencies": {
|
|
49
48
|
"vitest": "^4.1.8"
|
|
50
49
|
},
|
|
51
50
|
"overrides": {
|
|
52
|
-
"tar": "^
|
|
51
|
+
"tar": "^6.2.1"
|
|
53
52
|
}
|
|
54
53
|
}
|
package/pg-hook.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { embed, cosineSimilarity } from './embedder.js';
|
|
3
|
-
import { getDb } from './db.js';
|
|
3
|
+
import { getDb, blobToVec } from './db.js';
|
|
4
|
+
import { annSearch } from './ann.js';
|
|
4
5
|
|
|
5
6
|
let input = '';
|
|
6
7
|
process.stdin.on('data', d => input += d);
|
|
@@ -13,19 +14,34 @@ process.stdin.on('end', async () => {
|
|
|
13
14
|
const queryVec = await embed(prompt);
|
|
14
15
|
const db = getDb();
|
|
15
16
|
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
for (const chunk of chunks) {
|
|
20
|
-
const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
|
|
21
|
-
const prev = bestBySkill.get(chunk.skill_id);
|
|
22
|
-
if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
|
|
23
|
-
}
|
|
17
|
+
// Use ANN index (fast). Fall back to brute-force only if ANN unavailable.
|
|
18
|
+
let topIds;
|
|
19
|
+
const annResults = await annSearch(queryVec, 15);
|
|
24
20
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
21
|
+
if (annResults && annResults.length > 0) {
|
|
22
|
+
const bestBySkill = new Map();
|
|
23
|
+
for (const r of annResults) {
|
|
24
|
+
const prev = bestBySkill.get(r.skill_id);
|
|
25
|
+
if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
|
|
26
|
+
}
|
|
27
|
+
topIds = [...bestBySkill.entries()]
|
|
28
|
+
.sort((a, b) => b[1] - a[1])
|
|
29
|
+
.slice(0, 3)
|
|
30
|
+
.filter(([, score]) => score > 0.55);
|
|
31
|
+
} else {
|
|
32
|
+
// Fallback: brute-force (only before first reindex)
|
|
33
|
+
const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
|
|
34
|
+
const bestBySkill = new Map();
|
|
35
|
+
for (const chunk of chunks) {
|
|
36
|
+
const score = cosineSimilarity(queryVec, blobToVec(chunk.embedding));
|
|
37
|
+
const prev = bestBySkill.get(chunk.skill_id);
|
|
38
|
+
if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
|
|
39
|
+
}
|
|
40
|
+
topIds = [...bestBySkill.entries()]
|
|
41
|
+
.sort((a, b) => b[1] - a[1])
|
|
42
|
+
.slice(0, 3)
|
|
43
|
+
.filter(([, score]) => score > 0.55);
|
|
44
|
+
}
|
|
29
45
|
|
|
30
46
|
if (topIds.length === 0) process.exit(0);
|
|
31
47
|
|
package/platform.js
CHANGED
|
@@ -1,80 +1,102 @@
|
|
|
1
1
|
import os from 'os';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import { spawnSync } from 'child_process';
|
|
4
5
|
|
|
5
6
|
const HOME = os.homedir();
|
|
6
7
|
|
|
8
|
+
const STD_ENTRY = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
9
|
+
const OC_ENTRY = { type: 'local', command: ['npx', 'promptgraph-mcp'], enabled: true };
|
|
10
|
+
|
|
11
|
+
// Shared helper: write standard mcpServers entry (Claude Code, Cursor, Windsurf, Codex format)
|
|
12
|
+
function addStdMcp(configPath) {
|
|
13
|
+
const json = readJson(configPath) || {};
|
|
14
|
+
json.mcpServers = json.mcpServers || {};
|
|
15
|
+
json.mcpServers.promptgraph = STD_ENTRY;
|
|
16
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
17
|
+
writeJson(configPath, json);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Cline uses servers.promptgraph (not mcpServers)
|
|
21
|
+
function addClineMcp(configPath) {
|
|
22
|
+
const json = readJson(configPath) || {};
|
|
23
|
+
json.servers = json.servers || {};
|
|
24
|
+
json.servers.promptgraph = STD_ENTRY;
|
|
25
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
26
|
+
writeJson(configPath, json);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// OpenCode uses mcp.promptgraph with type/command/enabled
|
|
30
|
+
function addOpenCodeMcp(configPath) {
|
|
31
|
+
const json = readJson(configPath) || {};
|
|
32
|
+
json.mcp = json.mcp || {};
|
|
33
|
+
json.mcp.promptgraph = OC_ENTRY;
|
|
34
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
35
|
+
writeJson(configPath, json);
|
|
36
|
+
}
|
|
37
|
+
|
|
7
38
|
export const PLATFORMS = {
|
|
8
39
|
'claude-code': {
|
|
9
40
|
name: 'Claude Code',
|
|
10
41
|
configPath: path.join(HOME, '.claude', 'settings.json'),
|
|
11
|
-
addMcp: (config
|
|
12
|
-
const json = readJson(config.configPath);
|
|
13
|
-
json.mcpServers = json.mcpServers || {};
|
|
14
|
-
json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
15
|
-
writeJson(config.configPath, json);
|
|
16
|
-
},
|
|
42
|
+
addMcp: (config) => addStdMcp(config.configPath),
|
|
17
43
|
},
|
|
18
44
|
'claude-desktop': {
|
|
19
45
|
name: 'Claude Desktop',
|
|
20
46
|
configPath: getClaudeDesktopConfig(),
|
|
21
|
-
addMcp: (config
|
|
22
|
-
const json = readJson(config.configPath);
|
|
23
|
-
json.mcpServers = json.mcpServers || {};
|
|
24
|
-
json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
25
|
-
writeJson(config.configPath, json);
|
|
26
|
-
},
|
|
47
|
+
addMcp: (config) => addStdMcp(config.configPath),
|
|
27
48
|
},
|
|
28
49
|
'cline': {
|
|
29
50
|
name: 'Cline (VS Code)',
|
|
30
51
|
configPath: path.join(HOME, '.vscode', 'mcp.json'),
|
|
31
|
-
addMcp: (config
|
|
32
|
-
const json = readJson(config.configPath) || { servers: {} };
|
|
33
|
-
json.servers = json.servers || {};
|
|
34
|
-
json.servers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
35
|
-
fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
|
|
36
|
-
writeJson(config.configPath, json);
|
|
37
|
-
},
|
|
52
|
+
addMcp: (config) => addClineMcp(config.configPath),
|
|
38
53
|
},
|
|
39
54
|
'codex': {
|
|
40
55
|
name: 'OpenAI Codex CLI',
|
|
41
56
|
configPath: path.join(HOME, '.codex', 'config.json'),
|
|
42
|
-
addMcp: (config
|
|
43
|
-
const json = readJson(config.configPath) || {};
|
|
44
|
-
json.mcpServers = json.mcpServers || {};
|
|
45
|
-
json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
46
|
-
fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
|
|
47
|
-
writeJson(config.configPath, json);
|
|
48
|
-
},
|
|
57
|
+
addMcp: (config) => addStdMcp(config.configPath),
|
|
49
58
|
},
|
|
50
59
|
'cursor': {
|
|
51
60
|
name: 'Cursor',
|
|
52
61
|
configPath: path.join(HOME, '.cursor', 'mcp.json'),
|
|
53
|
-
addMcp: (config
|
|
54
|
-
const json = readJson(config.configPath) || { mcpServers: {} };
|
|
55
|
-
json.mcpServers.promptgraph = { command: 'npx', args: ['promptgraph-mcp'] };
|
|
56
|
-
fs.mkdirSync(path.dirname(config.configPath), { recursive: true });
|
|
57
|
-
writeJson(config.configPath, json);
|
|
58
|
-
},
|
|
62
|
+
addMcp: (config) => addStdMcp(config.configPath),
|
|
59
63
|
},
|
|
60
64
|
'windsurf': {
|
|
61
65
|
name: 'Windsurf',
|
|
62
66
|
configPath: path.join(HOME, '.codeium', 'windsurf', 'mcp_config.json'),
|
|
63
|
-
addMcp: (config
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
67
|
+
addMcp: (config) => addStdMcp(config.configPath),
|
|
68
|
+
},
|
|
69
|
+
'opencode': {
|
|
70
|
+
name: 'OpenCode',
|
|
71
|
+
configPath: getOpenCodeConfig(),
|
|
72
|
+
addMcp: (config) => addOpenCodeMcp(config.configPath),
|
|
69
73
|
},
|
|
70
74
|
};
|
|
71
75
|
|
|
72
76
|
export function detectPlatforms() {
|
|
73
77
|
return Object.entries(PLATFORMS)
|
|
74
|
-
.filter(([, p]) =>
|
|
78
|
+
.filter(([id, p]) => {
|
|
79
|
+
if (!p.configPath) return false;
|
|
80
|
+
if (fs.existsSync(path.dirname(p.configPath))) return true;
|
|
81
|
+
if (id === 'opencode') return isOpenCodeInstalled();
|
|
82
|
+
return false;
|
|
83
|
+
})
|
|
75
84
|
.map(([id, p]) => ({ id, ...p }));
|
|
76
85
|
}
|
|
77
86
|
|
|
87
|
+
function isOpenCodeInstalled() {
|
|
88
|
+
try {
|
|
89
|
+
const r = spawnSync('opencode', ['--version'], { encoding: 'utf8', timeout: 3000 });
|
|
90
|
+
return r.status === 0;
|
|
91
|
+
} catch { return false; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function getOpenCodeConfig() {
|
|
95
|
+
if (process.platform === 'win32') return path.join(HOME, 'AppData', 'Roaming', 'opencode', 'opencode.json');
|
|
96
|
+
if (process.platform === 'darwin') return path.join(HOME, 'Library', 'Application Support', 'opencode', 'opencode.json');
|
|
97
|
+
return path.join(HOME, '.config', 'opencode', 'opencode.json');
|
|
98
|
+
}
|
|
99
|
+
|
|
78
100
|
function getClaudeDesktopConfig() {
|
|
79
101
|
if (process.platform === 'win32') {
|
|
80
102
|
const base = process.env.LOCALAPPDATA || '';
|
package/search.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { embed, cosineSimilarity } from './embedder.js';
|
|
2
|
-
import { getDb } from './db.js';
|
|
2
|
+
import { getDb, blobToVec } from './db.js';
|
|
3
3
|
import { annSearch } from './ann.js';
|
|
4
4
|
|
|
5
5
|
function applyRatingBoost(db, id, score) {
|
|
@@ -28,10 +28,7 @@ export async function search(query, topK = 5) {
|
|
|
28
28
|
.map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
|
|
29
29
|
.sort((a, b) => b.score - a.score)
|
|
30
30
|
.slice(0, topK)
|
|
31
|
-
.map(({ id, score }) =>
|
|
32
|
-
const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
|
|
33
|
-
return skill ? { ...skill, score } : null;
|
|
34
|
-
})
|
|
31
|
+
.map(({ id, score }) => skillWithSnippet(db, id, score))
|
|
35
32
|
.filter(Boolean);
|
|
36
33
|
}
|
|
37
34
|
|
|
@@ -39,7 +36,7 @@ export async function search(query, topK = 5) {
|
|
|
39
36
|
const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
|
|
40
37
|
const bestBySkill = new Map();
|
|
41
38
|
for (const chunk of chunks) {
|
|
42
|
-
const score = cosineSimilarity(queryVec,
|
|
39
|
+
const score = cosineSimilarity(queryVec, blobToVec(chunk.embedding));
|
|
43
40
|
const prev = bestBySkill.get(chunk.skill_id);
|
|
44
41
|
if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
|
|
45
42
|
}
|
|
@@ -47,13 +44,18 @@ export async function search(query, topK = 5) {
|
|
|
47
44
|
.map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
|
|
48
45
|
.sort((a, b) => b.score - a.score)
|
|
49
46
|
.slice(0, topK)
|
|
50
|
-
.map(({ id, score }) =>
|
|
51
|
-
const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
|
|
52
|
-
return skill ? { ...skill, score } : null;
|
|
53
|
-
})
|
|
47
|
+
.map(({ id, score }) => skillWithSnippet(db, id, score))
|
|
54
48
|
.filter(Boolean);
|
|
55
49
|
}
|
|
56
50
|
|
|
51
|
+
function skillWithSnippet(db, id, score) {
|
|
52
|
+
const skill = db.prepare('SELECT id, name, description, path, source, content FROM skills WHERE id = ?').get(id);
|
|
53
|
+
if (!skill) return null;
|
|
54
|
+
const { content, ...rest } = skill;
|
|
55
|
+
const snippet = content?.replace(/^---[\s\S]*?---\n?/, '').trim().slice(0, 400) || '';
|
|
56
|
+
return { ...rest, score, snippet };
|
|
57
|
+
}
|
|
58
|
+
|
|
57
59
|
export function getContext(nameOrId) {
|
|
58
60
|
const db = getDb();
|
|
59
61
|
let skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(nameOrId);
|
|
@@ -108,7 +110,7 @@ export function getImpact(nameOrId) {
|
|
|
108
110
|
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(cur).map(r => r.from_skill);
|
|
109
111
|
queue.push(...callers);
|
|
110
112
|
}
|
|
111
|
-
visited.delete(id);
|
|
113
|
+
visited.delete(res.id);
|
|
112
114
|
return [...visited];
|
|
113
115
|
}
|
|
114
116
|
|
package/validator.js
CHANGED
|
@@ -87,6 +87,47 @@ export function validateSkill(filePath) {
|
|
|
87
87
|
return { ok: errors.length === 0, errors, warnings };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
const BUNDLE_ID_RE = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
91
|
+
|
|
92
|
+
export function validateBundle(def) {
|
|
93
|
+
const errors = [];
|
|
94
|
+
const warnings = [];
|
|
95
|
+
|
|
96
|
+
if (!def.id || typeof def.id !== 'string') {
|
|
97
|
+
errors.push('Missing required field: id');
|
|
98
|
+
} else if (!BUNDLE_ID_RE.test(def.id)) {
|
|
99
|
+
errors.push(`Invalid id "${def.id}". Use lowercase, digits, hyphens (2-64 chars).`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!def.name || typeof def.name !== 'string' || def.name.trim().length < 3) {
|
|
103
|
+
errors.push('Missing or too short field: name (min 3 chars)');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!def.description || typeof def.description !== 'string' || def.description.trim().length < 15) {
|
|
107
|
+
errors.push('Missing or too short field: description (min 15 chars)');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!Array.isArray(def.skills) || def.skills.length < 2) {
|
|
111
|
+
errors.push('Field "skills" must be an array with at least 2 skill IDs');
|
|
112
|
+
} else {
|
|
113
|
+
for (const s of def.skills) {
|
|
114
|
+
if (typeof s !== 'string' || !BUNDLE_ID_RE.test(s)) {
|
|
115
|
+
errors.push(`Invalid skill id in bundle: "${s}"`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (def.tags && !Array.isArray(def.tags)) {
|
|
121
|
+
errors.push('Field "tags" must be an array of strings');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (def.skills?.length > 20) {
|
|
125
|
+
warnings.push('Bundle has more than 20 skills — consider splitting into sub-bundles');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
129
|
+
}
|
|
130
|
+
|
|
90
131
|
// CLI: node validator.js <file>
|
|
91
132
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
92
133
|
const file = process.argv[2];
|
package/watcher.js
CHANGED
|
@@ -25,8 +25,11 @@ export function startWatcher() {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function getSource(filePath, config) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const normFile = path.resolve(filePath);
|
|
29
|
+
// Sort longest-first so skills-store/marketplace wins over skills-store
|
|
30
|
+
const sorted = [...config.sources].sort((a, b) => b.dir.length - a.dir.length);
|
|
31
|
+
for (const { dir, source } of sorted) {
|
|
32
|
+
if (normFile.startsWith(path.resolve(dir))) return source;
|
|
30
33
|
}
|
|
31
34
|
return 'unknown';
|
|
32
35
|
}
|