promptgraph-mcp 1.0.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 +159 -0
- package/config.js +51 -0
- package/db.js +32 -0
- package/embedder.js +32 -0
- package/index.js +121 -0
- package/indexer.js +65 -0
- package/package.json +35 -0
- package/parser.js +47 -0
- package/search.js +58 -0
- package/watcher.js +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# PromptGraph
|
|
2
|
+
|
|
3
|
+
Semantic skill router for Claude Code. Instead of loading all your skills into context on every request, PromptGraph indexes them with vector embeddings and loads only the relevant one on demand.
|
|
4
|
+
|
|
5
|
+
## The Problem
|
|
6
|
+
|
|
7
|
+
Claude Code loads all `.md` files from `~/.claude/commands/` into the system prompt on every session. With 40+ skills, that's **20,000–50,000 tokens wasted per conversation** — before you've even said hello.
|
|
8
|
+
|
|
9
|
+
## The Solution
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
~/.claude/commands/
|
|
13
|
+
pg.md ← one tiny router skill (~150 tokens)
|
|
14
|
+
|
|
15
|
+
~/.claude/skills-store/
|
|
16
|
+
game-audit.md
|
|
17
|
+
chain.md
|
|
18
|
+
hunt-sqli.md
|
|
19
|
+
... ← 40+ skills, NOT loaded into context
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
When you ask Claude a question, it calls `pg_search("your task")` → finds the right skill via vector search → reads only that file. **One skill loaded instead of forty.**
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Vector search** via `fastembed` (`BGE-Small-EN`, 23MB, runs locally, no API needed)
|
|
27
|
+
- **Semantic matching** — Russian query finds English skill, synonyms work
|
|
28
|
+
- **Auto-reindex** via file watcher when skills change
|
|
29
|
+
- **Graph edges** — tracks which skills call other skills
|
|
30
|
+
- **MCP server** — integrates directly into Claude Code and Claude Desktop
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
### Via npx (recommended)
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npx promptgraph init
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### From source
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/NeiP4n/promptgraph
|
|
44
|
+
cd promptgraph
|
|
45
|
+
npm install
|
|
46
|
+
npm link
|
|
47
|
+
promptgraph init
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`init` will:
|
|
51
|
+
1. Ask for extra skill directories (optional)
|
|
52
|
+
2. Download the embedding model (~23MB, one time)
|
|
53
|
+
3. Index all your skills
|
|
54
|
+
4. Print the config snippet to add to `settings.json`
|
|
55
|
+
|
|
56
|
+
## Setup
|
|
57
|
+
|
|
58
|
+
### Claude Code (`~/.claude/settings.json`)
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"mcpServers": {
|
|
63
|
+
"promptgraph": {
|
|
64
|
+
"command": "npx",
|
|
65
|
+
"args": ["promptgraph"]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Claude Desktop
|
|
72
|
+
|
|
73
|
+
Add the same block to `claude_desktop_config.json`.
|
|
74
|
+
|
|
75
|
+
### Router skill (`~/.claude/commands/pg.md`)
|
|
76
|
+
|
|
77
|
+
```markdown
|
|
78
|
+
---
|
|
79
|
+
name: pg
|
|
80
|
+
description: PromptGraph router — finds and loads the right skill for any task
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
# PromptGraph Router
|
|
84
|
+
|
|
85
|
+
You have access to a semantic skill index via the `promptgraph` MCP server.
|
|
86
|
+
|
|
87
|
+
## How to handle any task
|
|
88
|
+
|
|
89
|
+
1. Call `pg_search` with the user's task as query (in English)
|
|
90
|
+
2. Pick the top result with score > 0.6
|
|
91
|
+
3. Read the skill file at the returned `path`
|
|
92
|
+
4. Execute that skill's instructions
|
|
93
|
+
|
|
94
|
+
## If no good match (score < 0.6)
|
|
95
|
+
|
|
96
|
+
Handle the task directly without a skill.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Move all your other skills from `commands/` to `skills-store/`:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
mkdir -p ~/.claude/skills-store
|
|
103
|
+
mv ~/.claude/commands/*.md ~/.claude/skills-store/
|
|
104
|
+
mv ~/.claude/skills-store/pg.md ~/.claude/commands/
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Commands
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
promptgraph init # First-time setup (interactive)
|
|
111
|
+
promptgraph reindex # Re-index all skills
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## MCP Tools
|
|
115
|
+
|
|
116
|
+
| Tool | Description |
|
|
117
|
+
|---|---|
|
|
118
|
+
| `pg_search` | Semantic search by task description |
|
|
119
|
+
| `pg_list` | List all indexed skills |
|
|
120
|
+
| `pg_context` | Full details for a skill |
|
|
121
|
+
| `pg_callers` | Which skills reference this one |
|
|
122
|
+
| `pg_callees` | Which skills this one references |
|
|
123
|
+
| `pg_impact` | What breaks if this skill changes |
|
|
124
|
+
|
|
125
|
+
## Token Savings
|
|
126
|
+
|
|
127
|
+
| | Before | After |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| Skills in context | All 40+ | 1 (router) |
|
|
130
|
+
| Tokens per session | ~20,000–50,000 | ~150 + 1 skill |
|
|
131
|
+
| Scales to | ~50 skills | 5,000+ skills |
|
|
132
|
+
|
|
133
|
+
## File Structure
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
promptgraph/
|
|
137
|
+
index.js ← MCP server + CLI
|
|
138
|
+
config.js ← Config management
|
|
139
|
+
db.js ← SQLite setup
|
|
140
|
+
embedder.js ← fastembed wrapper
|
|
141
|
+
indexer.js ← Skill indexer
|
|
142
|
+
parser.js ← .md parser
|
|
143
|
+
search.js ← Vector search + graph queries
|
|
144
|
+
watcher.js ← File watcher (auto-reindex)
|
|
145
|
+
|
|
146
|
+
~/.claude/.promptgraph/
|
|
147
|
+
promptgraph.db ← SQLite index
|
|
148
|
+
model-cache/ ← Embedding model cache
|
|
149
|
+
config.json ← Skill directory config
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
## Requirements
|
|
153
|
+
|
|
154
|
+
- Node.js 18+
|
|
155
|
+
- Claude Code or Claude Desktop
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
*Generated with [Claude](https://claude.ai) by Anthropic*
|
package/config.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import readline from 'readline';
|
|
5
|
+
|
|
6
|
+
const CONFIG_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'config.json');
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
sources: [
|
|
10
|
+
{ dir: path.join(os.homedir(), '.claude', 'skills-store'), source: 'skills-store' },
|
|
11
|
+
{ dir: path.join(os.homedir(), '.claude', 'skills'), source: 'skills' },
|
|
12
|
+
{ dir: path.join(os.homedir(), '.claude', 'commands'), source: 'commands' },
|
|
13
|
+
],
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function loadConfig() {
|
|
17
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
18
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
return DEFAULTS;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function saveConfig(config) {
|
|
24
|
+
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
25
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function promptConfig() {
|
|
29
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
30
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
31
|
+
|
|
32
|
+
console.log('\n=== PromptGraph Setup ===\n');
|
|
33
|
+
console.log('Default skill directories:');
|
|
34
|
+
DEFAULTS.sources.forEach((s, i) => console.log(` ${i + 1}. ${s.dir}`));
|
|
35
|
+
|
|
36
|
+
const extra = await ask('\nAdd extra skill directories? (comma-separated paths, or press Enter to skip): ');
|
|
37
|
+
rl.close();
|
|
38
|
+
|
|
39
|
+
const config = { ...DEFAULTS };
|
|
40
|
+
|
|
41
|
+
if (extra.trim()) {
|
|
42
|
+
const extraDirs = extra.split(',').map(d => d.trim()).filter(Boolean);
|
|
43
|
+
for (const dir of extraDirs) {
|
|
44
|
+
config.sources.push({ dir, source: 'custom' });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
saveConfig(config);
|
|
49
|
+
console.log(`\nConfig saved to ${CONFIG_PATH}`);
|
|
50
|
+
return config;
|
|
51
|
+
}
|
package/db.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import Database from 'better-sqlite3';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
|
|
6
|
+
const DB_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
|
|
7
|
+
|
|
8
|
+
export function getDb() {
|
|
9
|
+
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
|
10
|
+
const db = new Database(DB_PATH);
|
|
11
|
+
db.pragma('journal_mode = WAL');
|
|
12
|
+
|
|
13
|
+
db.exec(`
|
|
14
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
15
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
16
|
+
name TEXT UNIQUE NOT NULL,
|
|
17
|
+
description TEXT,
|
|
18
|
+
path TEXT NOT NULL,
|
|
19
|
+
source TEXT NOT NULL,
|
|
20
|
+
content TEXT NOT NULL,
|
|
21
|
+
embedding TEXT
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
25
|
+
from_skill TEXT NOT NULL,
|
|
26
|
+
to_skill TEXT NOT NULL,
|
|
27
|
+
PRIMARY KEY (from_skill, to_skill)
|
|
28
|
+
);
|
|
29
|
+
`);
|
|
30
|
+
|
|
31
|
+
return db;
|
|
32
|
+
}
|
package/embedder.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { EmbeddingModel, FlagEmbedding } from 'fastembed';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
const CACHE_DIR = path.join(os.homedir(), '.claude', '.promptgraph', 'model-cache');
|
|
6
|
+
|
|
7
|
+
let model = null;
|
|
8
|
+
|
|
9
|
+
async function getModel() {
|
|
10
|
+
if (!model) {
|
|
11
|
+
model = await FlagEmbedding.init({
|
|
12
|
+
model: EmbeddingModel.BGESmallENV15,
|
|
13
|
+
cacheDir: CACHE_DIR,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return model;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function embed(text) {
|
|
20
|
+
const m = await getModel();
|
|
21
|
+
const results = [];
|
|
22
|
+
for await (const batch of m.embed([text])) {
|
|
23
|
+
results.push(...batch);
|
|
24
|
+
}
|
|
25
|
+
return Array.from(results[0]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function cosineSimilarity(a, b) {
|
|
29
|
+
let dot = 0;
|
|
30
|
+
for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
|
|
31
|
+
return dot;
|
|
32
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
5
|
+
import { search, getContext, getCallers, getCallees, getImpact, listAll } from './search.js';
|
|
6
|
+
import { indexAll } from './indexer.js';
|
|
7
|
+
import { startWatcher } from './watcher.js';
|
|
8
|
+
import { promptConfig } from './config.js';
|
|
9
|
+
|
|
10
|
+
const args = process.argv.slice(2);
|
|
11
|
+
|
|
12
|
+
if (args[0] === 'init') {
|
|
13
|
+
const config = await promptConfig();
|
|
14
|
+
console.log('\nIndexing skills...');
|
|
15
|
+
await indexAll();
|
|
16
|
+
console.log('\nDone! Add to Claude Code settings.json:');
|
|
17
|
+
console.log(JSON.stringify({
|
|
18
|
+
mcpServers: {
|
|
19
|
+
promptgraph: {
|
|
20
|
+
command: 'node',
|
|
21
|
+
args: [process.argv[1]],
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}, null, 2));
|
|
25
|
+
process.exit(0);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (args[0] === 'reindex') {
|
|
29
|
+
console.log('[PromptGraph] Reindexing...');
|
|
30
|
+
await indexAll();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const server = new Server(
|
|
35
|
+
{ name: 'promptgraph', version: '1.0.0' },
|
|
36
|
+
{ capabilities: { tools: {} } }
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
40
|
+
tools: [
|
|
41
|
+
{
|
|
42
|
+
name: 'pg_search',
|
|
43
|
+
description: 'Search skills by task description. Returns top relevant skills with scores.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
query: { type: 'string', description: 'Task or topic to search for' },
|
|
48
|
+
top_k: { type: 'number', description: 'Number of results (default 5)' },
|
|
49
|
+
},
|
|
50
|
+
required: ['query'],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'pg_list',
|
|
55
|
+
description: 'List all indexed skills.',
|
|
56
|
+
inputSchema: { type: 'object', properties: {} },
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'pg_context',
|
|
60
|
+
description: 'Get full context for a skill: description, content, callers, callees.',
|
|
61
|
+
inputSchema: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
properties: { name: { type: 'string' } },
|
|
64
|
+
required: ['name'],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
name: 'pg_callers',
|
|
69
|
+
description: 'Get skills that call/reference this skill.',
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: { name: { type: 'string' } },
|
|
73
|
+
required: ['name'],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
name: 'pg_callees',
|
|
78
|
+
description: 'Get skills that this skill calls/references.',
|
|
79
|
+
inputSchema: {
|
|
80
|
+
type: 'object',
|
|
81
|
+
properties: { name: { type: 'string' } },
|
|
82
|
+
required: ['name'],
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: 'pg_impact',
|
|
87
|
+
description: 'Get all skills that would be affected if this skill changes.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: { name: { type: 'string' } },
|
|
91
|
+
required: ['name'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
],
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
98
|
+
const { name, arguments: args } = request.params;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
let result;
|
|
102
|
+
switch (name) {
|
|
103
|
+
case 'pg_search': result = await search(args.query, args.top_k || 5); break;
|
|
104
|
+
case 'pg_list': result = listAll(); break;
|
|
105
|
+
case 'pg_context': result = getContext(args.name); break;
|
|
106
|
+
case 'pg_callers': result = getCallers(args.name); break;
|
|
107
|
+
case 'pg_callees': result = getCallees(args.name); break;
|
|
108
|
+
case 'pg_impact': result = getImpact(args.name); break;
|
|
109
|
+
default: throw new Error(`Unknown tool: ${name}`);
|
|
110
|
+
}
|
|
111
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
startWatcher();
|
|
118
|
+
|
|
119
|
+
const transport = new StdioServerTransport();
|
|
120
|
+
await server.connect(transport);
|
|
121
|
+
console.error('[PromptGraph] MCP server running');
|
package/indexer.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { globSync } from 'glob';
|
|
2
|
+
import { parseSkillFile } from './parser.js';
|
|
3
|
+
import { embed } from './embedder.js';
|
|
4
|
+
import { getDb } from './db.js';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
export async function indexAll() {
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
const db = getDb();
|
|
10
|
+
|
|
11
|
+
const upsert = db.prepare(`
|
|
12
|
+
INSERT INTO skills (name, description, path, source, content, embedding)
|
|
13
|
+
VALUES (@name, @description, @path, @source, @content, @embedding)
|
|
14
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
15
|
+
description = excluded.description,
|
|
16
|
+
path = excluded.path,
|
|
17
|
+
source = excluded.source,
|
|
18
|
+
content = excluded.content,
|
|
19
|
+
embedding = excluded.embedding
|
|
20
|
+
`);
|
|
21
|
+
const upsertEdge = db.prepare(`INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)`);
|
|
22
|
+
db.prepare('DELETE FROM edges').run();
|
|
23
|
+
|
|
24
|
+
let count = 0;
|
|
25
|
+
for (const { dir, source } of config.sources) {
|
|
26
|
+
const files = globSync(`${dir}/**/*.md`);
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
try {
|
|
29
|
+
const skill = parseSkillFile(file, source);
|
|
30
|
+
const embedding = await embed(skill.name + ' ' + skill.description + ' ' + skill.content.slice(0, 500));
|
|
31
|
+
upsert.run({ ...skill, embedding: JSON.stringify(embedding) });
|
|
32
|
+
for (const called of skill.calls) {
|
|
33
|
+
upsertEdge.run(skill.name, called);
|
|
34
|
+
}
|
|
35
|
+
count++;
|
|
36
|
+
process.stdout.write(`\r Indexed: ${count} skills`);
|
|
37
|
+
} catch (e) {
|
|
38
|
+
console.error(`\n Error indexing ${file}: ${e.message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
console.log(`\n Done. ${count} skills indexed.`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function indexFile(filePath, source) {
|
|
46
|
+
const db = getDb();
|
|
47
|
+
const skill = parseSkillFile(filePath, source);
|
|
48
|
+
const embedding = await embed(skill.name + ' ' + skill.description + ' ' + skill.content.slice(0, 500));
|
|
49
|
+
db.prepare(`
|
|
50
|
+
INSERT INTO skills (name, description, path, source, content, embedding)
|
|
51
|
+
VALUES (@name, @description, @path, @source, @content, @embedding)
|
|
52
|
+
ON CONFLICT(name) DO UPDATE SET
|
|
53
|
+
description = excluded.description,
|
|
54
|
+
path = excluded.path,
|
|
55
|
+
source = excluded.source,
|
|
56
|
+
content = excluded.content,
|
|
57
|
+
embedding = excluded.embedding
|
|
58
|
+
`).run({ ...skill, embedding: JSON.stringify(embedding) });
|
|
59
|
+
|
|
60
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ?').run(skill.name);
|
|
61
|
+
const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
|
|
62
|
+
for (const called of skill.calls) {
|
|
63
|
+
upsertEdge.run(skill.name, called);
|
|
64
|
+
}
|
|
65
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "promptgraph-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"main": "index.js",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"promptgraph": "./index.js",
|
|
8
|
+
"promptgraph-mcp": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node index.js",
|
|
12
|
+
"init": "node index.js init",
|
|
13
|
+
"reindex": "node index.js reindex"
|
|
14
|
+
},
|
|
15
|
+
"keywords": ["claude", "claude-code", "mcp", "ai", "skills", "embeddings"],
|
|
16
|
+
"author": "NeiP4n",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"description": "Semantic skill router for Claude Code — load only the skills you need, save 20k+ tokens per session",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/NeiP4n/promptgraph.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/NeiP4n/promptgraph#readme",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=18"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
29
|
+
"better-sqlite3": "^12.10.0",
|
|
30
|
+
"chokidar": "^5.0.0",
|
|
31
|
+
"fastembed": "^2.1.0",
|
|
32
|
+
"glob": "^13.0.6",
|
|
33
|
+
"gray-matter": "^4.0.3"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/parser.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
const SKILL_REF_RE = /\/([a-z0-9][a-z0-9-]+)/g;
|
|
5
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/;
|
|
6
|
+
|
|
7
|
+
export function parseSkillFile(filePath, source) {
|
|
8
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
9
|
+
|
|
10
|
+
let name = null;
|
|
11
|
+
let description = null;
|
|
12
|
+
let content = raw;
|
|
13
|
+
|
|
14
|
+
const fm = raw.match(FRONTMATTER_RE);
|
|
15
|
+
if (fm) {
|
|
16
|
+
try {
|
|
17
|
+
const nameMatch = fm[1].match(/^name:\s*(.+)$/m);
|
|
18
|
+
const descMatch = fm[1].match(/^description:\s*(.+)$/m);
|
|
19
|
+
if (nameMatch) name = nameMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
20
|
+
if (descMatch) description = descMatch[1].trim().replace(/^["']|["']$/g, '');
|
|
21
|
+
} catch {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
name = name || path.basename(filePath, '.md');
|
|
25
|
+
description = description || extractFirstParagraph(raw);
|
|
26
|
+
|
|
27
|
+
const calls = new Set();
|
|
28
|
+
for (const match of raw.matchAll(SKILL_REF_RE)) {
|
|
29
|
+
const ref = match[1];
|
|
30
|
+
if (ref !== name && ref.length > 2) calls.add(ref);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
description,
|
|
36
|
+
path: filePath,
|
|
37
|
+
source,
|
|
38
|
+
content: raw,
|
|
39
|
+
calls: [...calls],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractFirstParagraph(content) {
|
|
44
|
+
const lines = content.replace(FRONTMATTER_RE, '').split('\n')
|
|
45
|
+
.filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---'));
|
|
46
|
+
return lines[0]?.trim().slice(0, 200) || '';
|
|
47
|
+
}
|
package/search.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { embed, cosineSimilarity } from './embedder.js';
|
|
2
|
+
import { getDb } from './db.js';
|
|
3
|
+
|
|
4
|
+
export async function search(query, topK = 5) {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const queryVec = await embed(query);
|
|
7
|
+
const skills = db.prepare('SELECT name, description, path, source, embedding FROM skills').all();
|
|
8
|
+
|
|
9
|
+
return skills
|
|
10
|
+
.map(skill => ({
|
|
11
|
+
name: skill.name,
|
|
12
|
+
description: skill.description,
|
|
13
|
+
path: skill.path,
|
|
14
|
+
source: skill.source,
|
|
15
|
+
score: cosineSimilarity(queryVec, JSON.parse(skill.embedding)),
|
|
16
|
+
}))
|
|
17
|
+
.sort((a, b) => b.score - a.score)
|
|
18
|
+
.slice(0, topK);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getContext(name) {
|
|
22
|
+
const db = getDb();
|
|
23
|
+
const skill = db.prepare('SELECT * FROM skills WHERE name = ?').get(name);
|
|
24
|
+
if (!skill) return null;
|
|
25
|
+
const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(name).map(r => r.to_skill);
|
|
26
|
+
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(name).map(r => r.from_skill);
|
|
27
|
+
return { ...skill, embedding: undefined, callees, callers };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getCallers(name) {
|
|
31
|
+
const db = getDb();
|
|
32
|
+
return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(name).map(r => r.from_skill);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getCallees(name) {
|
|
36
|
+
const db = getDb();
|
|
37
|
+
return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(name).map(r => r.to_skill);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getImpact(name) {
|
|
41
|
+
const db = getDb();
|
|
42
|
+
const visited = new Set();
|
|
43
|
+
const queue = [name];
|
|
44
|
+
while (queue.length) {
|
|
45
|
+
const cur = queue.shift();
|
|
46
|
+
if (visited.has(cur)) continue;
|
|
47
|
+
visited.add(cur);
|
|
48
|
+
const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(cur).map(r => r.from_skill);
|
|
49
|
+
queue.push(...callers);
|
|
50
|
+
}
|
|
51
|
+
visited.delete(name);
|
|
52
|
+
return [...visited];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listAll() {
|
|
56
|
+
const db = getDb();
|
|
57
|
+
return db.prepare('SELECT name, description, source FROM skills ORDER BY name').all();
|
|
58
|
+
}
|
package/watcher.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import chokidar from 'chokidar';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { indexFile } from './indexer.js';
|
|
5
|
+
import { getDb } from './db.js';
|
|
6
|
+
|
|
7
|
+
const SOURCES = [
|
|
8
|
+
{ dir: path.join(os.homedir(), '.claude', 'skills-store'), source: 'skills-store' },
|
|
9
|
+
{ dir: path.join(os.homedir(), '.claude', 'skills'), source: 'skills' },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function startWatcher() {
|
|
13
|
+
const paths = SOURCES.map(s => s.dir);
|
|
14
|
+
|
|
15
|
+
const watcher = chokidar.watch(paths, {
|
|
16
|
+
ignored: /[/\\]\./,
|
|
17
|
+
persistent: true,
|
|
18
|
+
ignoreInitial: true,
|
|
19
|
+
awaitWriteFinish: { stabilityThreshold: 500 },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
watcher.on('add', filePath => reindex(filePath));
|
|
23
|
+
watcher.on('change', filePath => reindex(filePath));
|
|
24
|
+
watcher.on('unlink', filePath => {
|
|
25
|
+
const name = path.basename(filePath, '.md');
|
|
26
|
+
const db = getDb();
|
|
27
|
+
db.prepare('DELETE FROM skills WHERE name = ?').run(name);
|
|
28
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(name, name);
|
|
29
|
+
console.error(`[PromptGraph] Removed: ${name}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.error('[PromptGraph] Watcher started');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getSource(filePath) {
|
|
36
|
+
for (const { dir, source } of SOURCES) {
|
|
37
|
+
if (filePath.startsWith(dir)) return source;
|
|
38
|
+
}
|
|
39
|
+
return 'unknown';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function reindex(filePath) {
|
|
43
|
+
if (!filePath.endsWith('.md')) return;
|
|
44
|
+
try {
|
|
45
|
+
await indexFile(filePath, getSource(filePath));
|
|
46
|
+
console.error(`[PromptGraph] Reindexed: ${path.basename(filePath)}`);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error(`[PromptGraph] Error reindexing ${filePath}: ${e.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|