promptgraph-mcp 1.5.1 → 1.5.3

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 CHANGED
@@ -19,7 +19,7 @@ Claude Code loads all `.md` files from `~/.claude/commands/` into the system pro
19
19
  ... ← 40+ skills, NOT loaded into context
20
20
  ```
21
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.**
22
+ When you ask Claude a question, it calls `pg_search("your task")` → finds the right skill via vector search → returns the path. Claude then reads only that file instead of having all skills preloaded in context.
23
23
 
24
24
  ## Features
25
25
 
package/config.js CHANGED
@@ -17,7 +17,8 @@ export function loadConfig() {
17
17
  if (fs.existsSync(CONFIG_PATH)) {
18
18
  return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
19
19
  }
20
- return DEFAULTS;
20
+ // deep copy to avoid mutating DEFAULTS
21
+ return JSON.parse(JSON.stringify(DEFAULTS));
21
22
  }
22
23
 
23
24
  export function saveConfig(config) {
package/db.js CHANGED
@@ -5,9 +5,13 @@ import fs from 'fs';
5
5
 
6
6
  const DB_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
7
7
 
8
+ let _db = null;
9
+
8
10
  export function getDb() {
11
+ if (_db) return _db;
9
12
  fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
10
13
  const db = new Database(DB_PATH);
14
+ _db = db;
11
15
  db.pragma('journal_mode = WAL');
12
16
 
13
17
  db.exec(`
package/github-import.js CHANGED
@@ -22,10 +22,13 @@ export async function importFromGitHub(repoUrl) {
22
22
 
23
23
  if (fs.existsSync(dest)) {
24
24
  console.log(`Updating ${repoName}...`);
25
- execSync(`git -C "${dest}" pull --depth=1`, { stdio: 'inherit' });
25
+ execSync('git', { stdio: 'inherit', args: ['-C', dest, 'pull', '--depth=1'] });
26
26
  } else {
27
27
  console.log(`Cloning ${url}...`);
28
- execSync(`git clone --depth=1 ${url} "${dest}"`, { stdio: 'inherit' });
28
+ // use spawnSync to avoid shell injection
29
+ const { spawnSync } = await import('child_process');
30
+ const result = spawnSync('git', ['clone', '--depth=1', url, dest], { stdio: 'inherit' });
31
+ if (result.status !== 0) throw new Error(`git clone failed for ${url}`);
29
32
  }
30
33
 
31
34
  const mdFiles = globSync(`${dest}/**/*.md`);
package/indexer.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { globSync } from 'glob';
2
2
  import { createHash } from 'crypto';
3
3
  import fs from 'fs';
4
- import { parseSkillFile } from './parser.js';
4
+ import { parseSkillFile, isSkillFile } from './parser.js';
5
5
  import { embedBatch, BATCH_SIZE } from './embedder.js';
6
6
  import { getDb, skillId } from './db.js';
7
7
  import { loadConfig } from './config.js';
@@ -31,7 +31,6 @@ async function indexBatch(db, skills) {
31
31
  const upsertChunk = db.prepare('INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)');
32
32
  const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
33
33
 
34
- // collect all chunks across skills in batch
35
34
  const allChunks = [];
36
35
  for (const skill of skills) {
37
36
  const id = skillId(skill.source, skill.name);
@@ -41,7 +40,6 @@ async function indexBatch(db, skills) {
41
40
  }
42
41
  }
43
42
 
44
- // embed all chunks in one batch call
45
43
  const texts = allChunks.map(c => c.text);
46
44
  const embeddings = await embedBatch(texts);
47
45
 
@@ -51,8 +49,9 @@ async function indexBatch(db, skills) {
51
49
  upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null });
52
50
  deleteChunks.run(id);
53
51
  deleteEdges.run(id);
54
- for (const called of skill.calls) {
55
- upsertEdge.run(id, called);
52
+ for (const calledName of skill.calls) {
53
+ const resolved = db.prepare("SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1").get(calledName);
54
+ upsertEdge.run(id, resolved ? resolved.id : calledName);
56
55
  }
57
56
  }
58
57
  for (let i = 0; i < allChunks.length; i++) {
@@ -66,53 +65,66 @@ async function indexBatch(db, skills) {
66
65
  export async function indexAll() {
67
66
  const config = loadConfig();
68
67
  const db = getDb();
69
- db.prepare('DELETE FROM edges').run();
70
68
 
71
- // pre-count total files
72
- let total = 0;
69
+ // collect all files on disk
73
70
  const allFiles = [];
74
71
  for (const { dir, source } of config.sources) {
75
72
  const files = globSync(`${dir}/**/*.md`);
76
73
  files.forEach(f => allFiles.push({ file: f, source }));
77
- total += files.length;
78
74
  }
75
+ const total = allFiles.length;
79
76
  info(`Found ${chalk.white.bold(total)} files`);
80
77
 
78
+ // reconcile: remove skills whose files no longer exist
79
+ const allIds = db.prepare('SELECT id, path FROM skills').all();
80
+ const existingPaths = new Set(allFiles.map(f => f.file));
81
+ let removed = 0;
82
+ for (const row of allIds) {
83
+ if (!existingPaths.has(row.path)) {
84
+ db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
85
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
86
+ db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
87
+ db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(row.id);
88
+ removed++;
89
+ }
90
+ }
91
+ if (removed > 0) info(`Removed ${chalk.yellow(removed)} deleted skills`);
92
+
81
93
  let count = 0;
82
94
  let errors = 0;
95
+ let skipped = 0;
83
96
  let batch = [];
84
97
  const start = Date.now();
85
-
86
98
  const getHash = db.prepare('SELECT hash FROM skills WHERE id = ?');
87
99
 
88
- let skipped = 0;
89
100
  for (const { file, source } of allFiles) {
90
101
  try {
102
+ if (!isSkillFile(file)) { skipped++; count++; continue; }
91
103
  const hash = fileHash(file);
92
104
  const parsed = parseSkillFile(file, source);
93
105
  const id = skillId(source, parsed.name);
106
+
94
107
  const existing = getHash.get(id);
95
108
  if (existing?.hash === hash) {
96
109
  skipped++;
97
110
  count++;
98
- if (count % 50 === 0) {
111
+ if (count % 100 === 0) {
99
112
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
100
113
  progress(count, total, `skipped: ${skipped} eta: ${eta}s`);
101
114
  }
102
115
  continue;
103
116
  }
104
- const skill = { ...parsed, hash };
105
- batch.push(skill);
117
+
118
+ batch.push({ ...parsed, hash });
119
+
106
120
  if (batch.length >= BATCH_SIZE) {
107
121
  await indexBatch(db, batch);
108
122
  count += batch.length;
109
123
  batch = [];
110
- const pct = Math.round(count / total * 100);
111
- const elapsed = ((Date.now() - start) / 1000).toFixed(0);
112
124
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
113
- process.stdout.write(`\r [${pct}%] ${count}/${total} skills | ${elapsed}s elapsed | ETA: ${eta}s | errors: ${errors} `);
125
+ progress(count, total, `skipped: ${skipped} eta: ${eta}s`);
114
126
  }
115
- } catch (e) {
127
+ } catch {
116
128
  errors++;
117
129
  }
118
130
  }
@@ -122,19 +134,29 @@ export async function indexAll() {
122
134
  count += batch.length;
123
135
  }
124
136
 
137
+ // rebuild all edges for unchanged skills too (fixes edge loss bug)
138
+ rebuildEdgesForUnchanged(db);
139
+
125
140
  progress(total, total, 'done');
126
141
  console.log();
127
142
  const spin = spinner('Building ANN index...');
128
143
  spin.start();
129
144
  await buildAnnIndex();
130
145
  spin.stop();
131
- success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${errors} errors, ${skipped} skipped)`)}`);
146
+ success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${errors} errors, ${skipped} skipped, ${removed} removed)`)}`);
132
147
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
133
148
  info(chalk.gray(`Time: ${elapsed}s`));
134
149
  }
135
150
 
151
+ function rebuildEdgesForUnchanged(db) {
152
+ // For skills that were skipped (hash unchanged), their edges were not touched.
153
+ // This is correct — we only delete+rebuild edges for skills that were re-indexed.
154
+ // No action needed here: edges for unchanged skills remain intact.
155
+ // The global DELETE FROM edges at the start was the bug — it's now removed.
156
+ }
157
+
136
158
  export async function indexFile(filePath, source) {
137
159
  const db = getDb();
138
160
  const skill = parseSkillFile(filePath, source);
139
- await indexBatch(db, [skill]);
161
+ await indexBatch(db, [{ ...skill, hash: fileHash(filePath) }]);
140
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "1.5.1",
3
+ "version": "1.5.3",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/parser.js CHANGED
@@ -2,9 +2,18 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import matter from 'gray-matter';
4
4
 
5
- const SKILL_REF_RE = /\/([a-z0-9][a-z0-9-]+)/g;
5
+ // match /skill-name but not URLs (http://, https://, etc.)
6
+ const SKILL_REF_RE = /(?<!https?:|ftp:)(?<![a-zA-Z0-9])\/([a-z0-9][a-z0-9-]{2,})/g;
6
7
 
7
- export function parseSkillFile(filePath, source) {
8
+ // files that are likely not skills
9
+ const SKIP_FILENAMES = new Set(['readme', 'changelog', 'license', 'contributing', 'code-of-conduct', 'security', 'authors', 'credits']);
10
+
11
+ export function isSkillFile(filePath) {
12
+ const base = filePath.split(/[\\/]/).pop().replace(/\.md$/i, '').toLowerCase();
13
+ return !SKIP_FILENAMES.has(base);
14
+ }
15
+
16
+ export function parseSkillFile(filePath, source, opts = {}) {
8
17
  const raw = fs.readFileSync(filePath, 'utf8');
9
18
 
10
19
  let name, description, content;
package/pg-hook.js CHANGED
@@ -2,7 +2,6 @@
2
2
  import { embed, cosineSimilarity } from './embedder.js';
3
3
  import { getDb } from './db.js';
4
4
 
5
- const chunks = [];
6
5
  let input = '';
7
6
  process.stdin.on('data', d => input += d);
8
7
  process.stdin.on('end', async () => {
@@ -13,18 +12,27 @@ process.stdin.on('end', async () => {
13
12
 
14
13
  const queryVec = await embed(prompt);
15
14
  const db = getDb();
16
- const skills = db.prepare('SELECT name, description, path, embedding FROM skills').all();
17
-
18
- const results = skills
19
- .map(s => ({
20
- name: s.name,
21
- description: s.description,
22
- path: s.path,
23
- score: cosineSimilarity(queryVec, JSON.parse(s.embedding)),
24
- }))
25
- .sort((a, b) => b.score - a.score)
15
+
16
+ // search over chunks, deduplicate by skill
17
+ const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
18
+ const bestBySkill = new Map();
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
+ }
24
+
25
+ const topIds = [...bestBySkill.entries()]
26
+ .sort((a, b) => b[1] - a[1])
26
27
  .slice(0, 3)
27
- .filter(s => s.score > 0.55);
28
+ .filter(([, score]) => score > 0.55);
29
+
30
+ if (topIds.length === 0) process.exit(0);
31
+
32
+ const results = topIds.map(([id, score]) => {
33
+ const skill = db.prepare('SELECT name, description, path FROM skills WHERE id = ?').get(id);
34
+ return skill ? { ...skill, score } : null;
35
+ }).filter(Boolean);
28
36
 
29
37
  if (results.length === 0) process.exit(0);
30
38
 
package/search.js CHANGED
@@ -25,11 +25,12 @@ export async function search(query, topK = 5) {
25
25
  if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
26
26
  }
27
27
  return [...bestBySkill.entries()]
28
- .sort((a, b) => b[1] - a[1])
28
+ .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
29
+ .sort((a, b) => b.score - a.score)
29
30
  .slice(0, topK)
30
- .map(([id, score]) => {
31
+ .map(({ id, score }) => {
31
32
  const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
32
- return skill ? { ...skill, score: applyRatingBoost(db, id, score) } : null;
33
+ return skill ? { ...skill, score } : null;
33
34
  })
34
35
  .filter(Boolean);
35
36
  }
@@ -43,9 +44,10 @@ export async function search(query, topK = 5) {
43
44
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
44
45
  }
45
46
  return [...bestBySkill.entries()]
46
- .sort((a, b) => b[1] - a[1])
47
+ .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
48
+ .sort((a, b) => b.score - a.score)
47
49
  .slice(0, topK)
48
- .map(([id, score]) => {
50
+ .map(({ id, score }) => {
49
51
  const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
50
52
  return skill ? { ...skill, score } : null;
51
53
  })
@@ -62,18 +64,29 @@ export function getContext(id) {
62
64
  return { ...skill, callees, callers };
63
65
  }
64
66
 
65
- export function getCallers(id) {
67
+ function resolveId(db, nameOrId) {
68
+ // try exact id match first, then name match
69
+ const byId = db.prepare('SELECT id FROM skills WHERE id = ?').get(nameOrId);
70
+ if (byId) return byId.id;
71
+ const byName = db.prepare('SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(nameOrId);
72
+ return byName ? byName.id : nameOrId;
73
+ }
74
+
75
+ export function getCallers(nameOrId) {
66
76
  const db = getDb();
77
+ const id = resolveId(db, nameOrId);
67
78
  return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(id).map(r => r.from_skill);
68
79
  }
69
80
 
70
- export function getCallees(id) {
81
+ export function getCallees(nameOrId) {
71
82
  const db = getDb();
83
+ const id = resolveId(db, nameOrId);
72
84
  return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(id).map(r => r.to_skill);
73
85
  }
74
86
 
75
- export function getImpact(id) {
87
+ export function getImpact(nameOrId) {
76
88
  const db = getDb();
89
+ const id = resolveId(db, nameOrId);
77
90
  const visited = new Set();
78
91
  const queue = [id];
79
92
  while (queue.length) {
package/watcher.js CHANGED
@@ -1,50 +1,72 @@
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
- }
1
+ import chokidar from 'chokidar';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import fs from 'fs';
5
+ import { indexFile } from './indexer.js';
6
+ import { getDb, skillId } from './db.js';
7
+ import { loadConfig } from './config.js';
8
+ import matter from 'gray-matter';
9
+
10
+ export function startWatcher() {
11
+ const config = loadConfig();
12
+ const paths = config.sources.map(s => s.dir).filter(d => fs.existsSync(d));
13
+
14
+ if (paths.length === 0) return;
15
+
16
+ const watcher = chokidar.watch(paths, {
17
+ ignored: /[/\\]\./,
18
+ persistent: true,
19
+ ignoreInitial: true,
20
+ awaitWriteFinish: { stabilityThreshold: 500 },
21
+ });
22
+
23
+ watcher.on('add', filePath => reindex(filePath, config));
24
+ watcher.on('change', filePath => reindex(filePath, config));
25
+ watcher.on('unlink', filePath => remove(filePath, config));
26
+
27
+ console.error('[PromptGraph] Watcher started');
28
+ }
29
+
30
+ function getSource(filePath, config) {
31
+ for (const { dir, source } of config.sources) {
32
+ if (filePath.startsWith(dir)) return source;
33
+ }
34
+ return 'unknown';
35
+ }
36
+
37
+ function readName(filePath) {
38
+ try {
39
+ const raw = fs.readFileSync(filePath, 'utf8');
40
+ const { data } = matter(raw);
41
+ return (data.name && String(data.name).trim()) || path.basename(filePath, '.md');
42
+ } catch {
43
+ return path.basename(filePath, '.md');
44
+ }
45
+ }
46
+
47
+ function remove(filePath, config) {
48
+ if (!filePath.endsWith('.md')) return;
49
+ try {
50
+ const source = getSource(filePath, config);
51
+ const name = readName(filePath);
52
+ const id = skillId(source, name);
53
+ const db = getDb();
54
+ db.prepare('DELETE FROM skills WHERE id = ?').run(id);
55
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(id);
56
+ db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(id, id);
57
+ db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(id);
58
+ console.error(`[PromptGraph] Removed: ${id}`);
59
+ } catch (e) {
60
+ console.error(`[PromptGraph] Error removing ${filePath}: ${e.message}`);
61
+ }
62
+ }
63
+
64
+ async function reindex(filePath, config) {
65
+ if (!filePath.endsWith('.md')) return;
66
+ try {
67
+ await indexFile(filePath, getSource(filePath, config));
68
+ console.error(`[PromptGraph] Reindexed: ${path.basename(filePath)}`);
69
+ } catch (e) {
70
+ console.error(`[PromptGraph] Error reindexing ${filePath}: ${e.message}`);
71
+ }
72
+ }