promptgraph-mcp 1.5.2 → 1.5.4

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/cli.js CHANGED
@@ -25,61 +25,76 @@ export function banner() {
25
25
  boxen(
26
26
  colors.primary.bold('PromptGraph') + ' ' + colors.muted('v' + getVersion()) + '\n' +
27
27
  colors.muted('Semantic skill router for Claude Code'),
28
- {
29
- padding: { top: 0, bottom: 0, left: 2, right: 2 },
30
- borderStyle: 'round',
31
- borderColor: '#7C3AED',
32
- dimBorder: true,
33
- }
28
+ { padding: { top: 0, bottom: 0, left: 2, right: 2 }, borderStyle: 'round', borderColor: '#7C3AED', dimBorder: true }
34
29
  )
35
30
  );
36
31
  }
37
32
 
38
33
  export function spinner(text) {
39
- return ora({
40
- text: colors.muted(text),
41
- spinner: 'dots',
42
- color: 'magenta',
43
- });
34
+ return ora({ text: colors.muted(text), spinner: 'dots', color: 'magenta' });
44
35
  }
45
36
 
46
37
  export function success(msg) {
47
- console.log(colors.success('✓') + ' ' + msg);
38
+ console.log('\n' + colors.success('✓') + ' ' + msg);
48
39
  }
49
40
 
50
41
  export function error(msg) {
51
- console.log(colors.error('✗') + ' ' + msg);
42
+ console.log(colors.error('✗') + ' ' + msg);
52
43
  }
53
44
 
54
45
  export function info(msg) {
55
- console.log(colors.muted('·') + ' ' + msg);
46
+ console.log(colors.muted(' ' + msg));
56
47
  }
57
48
 
58
49
  export function section(title) {
59
50
  console.log('\n' + colors.primary.bold(title));
60
51
  }
61
52
 
62
- export function progress(current, total, extra = '') {
53
+ let _progressActive = false;
54
+
55
+ export function progress(current, total, { skipped = 0, eta = '?', errors = 0 } = {}) {
63
56
  const pct = Math.round(current / total * 100);
64
57
  const bar = buildBar(pct);
65
- process.stdout.write(
66
- `\r ${bar} ${colors.white.bold(pct + '%')} ${colors.muted(current + '/' + total)} ${colors.muted(extra)} `
67
- );
58
+
59
+ const stats = [
60
+ colors.white.bold(String(pct).padStart(3) + '%'),
61
+ colors.muted(current + '/' + total),
62
+ skipped > 0 ? colors.muted('skip ' + skipped) : '',
63
+ errors > 0 ? colors.error('err ' + errors) : '',
64
+ eta !== '?' ? colors.muted('eta ' + formatTime(eta)) : '',
65
+ ].filter(Boolean).join(' ');
66
+
67
+ process.stdout.write('\r ' + bar + ' ' + stats + ' ');
68
+ _progressActive = true;
69
+ }
70
+
71
+ export function progressDone() {
72
+ if (_progressActive) {
73
+ process.stdout.write('\n');
74
+ _progressActive = false;
75
+ }
68
76
  }
69
77
 
70
78
  function buildBar(pct) {
71
- const width = 20;
79
+ const width = 24;
72
80
  const filled = Math.round(pct / 100 * width);
73
81
  const empty = width - filled;
74
82
  return colors.primary('█'.repeat(filled)) + colors.muted('░'.repeat(empty));
75
83
  }
76
84
 
85
+ function formatTime(seconds) {
86
+ if (seconds < 60) return seconds + 's';
87
+ const m = Math.floor(seconds / 60);
88
+ const s = seconds % 60;
89
+ return m + 'm ' + s + 's';
90
+ }
91
+
77
92
  export function table(rows) {
78
93
  if (!rows.length) { info('No results'); return; }
79
94
  const cols = Object.keys(rows[0]);
80
95
  const widths = cols.map(c => Math.max(c.length, ...rows.map(r => String(r[c] ?? '').length)));
81
96
  const header = cols.map((c, i) => colors.muted(c.toUpperCase().padEnd(widths[i]))).join(' ');
82
- const divider = colors.muted(widths.map(w => '─'.repeat(w)).join(' '));
97
+ const divider = colors.muted(widths.map(w => '─'.repeat(w)).join('──'));
83
98
  console.log('\n' + header);
84
99
  console.log(divider);
85
100
  for (const row of rows) {
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/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
@@ -7,7 +7,7 @@ import { getDb, skillId } from './db.js';
7
7
  import { loadConfig } from './config.js';
8
8
  import { chunkText } from './chunker.js';
9
9
  import { buildAnnIndex } from './ann.js';
10
- import { progress, success, info, spinner } from './cli.js';
10
+ import { progress, progressDone, success, info, spinner } from './cli.js';
11
11
  import chalk from 'chalk';
12
12
 
13
13
  function fileHash(filePath) {
@@ -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
 
@@ -52,7 +50,6 @@ async function indexBatch(db, skills) {
52
50
  deleteChunks.run(id);
53
51
  deleteEdges.run(id);
54
52
  for (const calledName of skill.calls) {
55
- // try to resolve to a real skill id, fallback to bare name
56
53
  const resolved = db.prepare("SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1").get(calledName);
57
54
  upsertEdge.run(id, resolved ? resolved.id : calledName);
58
55
  }
@@ -68,54 +65,66 @@ async function indexBatch(db, skills) {
68
65
  export async function indexAll() {
69
66
  const config = loadConfig();
70
67
  const db = getDb();
71
- db.prepare('DELETE FROM edges').run();
72
68
 
73
- // pre-count total files
74
- let total = 0;
69
+ // collect all files on disk
75
70
  const allFiles = [];
76
71
  for (const { dir, source } of config.sources) {
77
72
  const files = globSync(`${dir}/**/*.md`);
78
73
  files.forEach(f => allFiles.push({ file: f, source }));
79
- total += files.length;
80
74
  }
75
+ const total = allFiles.length;
81
76
  info(`Found ${chalk.white.bold(total)} files`);
82
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
+
83
93
  let count = 0;
84
94
  let errors = 0;
95
+ let skipped = 0;
85
96
  let batch = [];
86
97
  const start = Date.now();
87
-
88
98
  const getHash = db.prepare('SELECT hash FROM skills WHERE id = ?');
89
99
 
90
- let skipped = 0;
91
100
  for (const { file, source } of allFiles) {
92
101
  try {
93
102
  if (!isSkillFile(file)) { skipped++; count++; continue; }
94
103
  const hash = fileHash(file);
95
104
  const parsed = parseSkillFile(file, source);
96
105
  const id = skillId(source, parsed.name);
106
+
97
107
  const existing = getHash.get(id);
98
108
  if (existing?.hash === hash) {
99
109
  skipped++;
100
110
  count++;
101
- if (count % 50 === 0) {
111
+ if (count % 100 === 0) {
102
112
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
103
- progress(count, total, `skipped: ${skipped} eta: ${eta}s`);
113
+ progress(count, total, { skipped, eta, errors });
104
114
  }
105
115
  continue;
106
116
  }
107
- const skill = { ...parsed, hash };
108
- batch.push(skill);
117
+
118
+ batch.push({ ...parsed, hash });
119
+
109
120
  if (batch.length >= BATCH_SIZE) {
110
121
  await indexBatch(db, batch);
111
122
  count += batch.length;
112
123
  batch = [];
113
- const pct = Math.round(count / total * 100);
114
- const elapsed = ((Date.now() - start) / 1000).toFixed(0);
115
124
  const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
116
- process.stdout.write(`\r [${pct}%] ${count}/${total} skills | ${elapsed}s elapsed | ETA: ${eta}s | errors: ${errors} `);
125
+ progress(count, total, { skipped, eta, errors });
117
126
  }
118
- } catch (e) {
127
+ } catch {
119
128
  errors++;
120
129
  }
121
130
  }
@@ -125,19 +134,29 @@ export async function indexAll() {
125
134
  count += batch.length;
126
135
  }
127
136
 
128
- progress(total, total, 'done');
129
- console.log();
137
+ // rebuild all edges for unchanged skills too (fixes edge loss bug)
138
+ rebuildEdgesForUnchanged(db);
139
+
140
+ progress(total, total, { skipped, errors });
141
+ progressDone();
130
142
  const spin = spinner('Building ANN index...');
131
143
  spin.start();
132
144
  await buildAnnIndex();
133
145
  spin.stop();
134
- 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)`)}`);
135
147
  const elapsed = ((Date.now() - start) / 1000).toFixed(1);
136
148
  info(chalk.gray(`Time: ${elapsed}s`));
137
149
  }
138
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
+
139
158
  export async function indexFile(filePath, source) {
140
159
  const db = getDb();
141
160
  const skill = parseSkillFile(filePath, source);
142
- await indexBatch(db, [skill]);
161
+ await indexBatch(db, [{ ...skill, hash: fileHash(filePath) }]);
143
162
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "1.5.2",
3
+ "version": "1.5.4",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
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,12 +44,12 @@ 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
- // apply same rating boost as ANN path
49
- .map(([id, score]) => {
50
+ .map(({ id, score }) => {
50
51
  const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
51
- return skill ? { ...skill, score: applyRatingBoost(db, id, score) } : null;
52
+ return skill ? { ...skill, score } : null;
52
53
  })
53
54
  .filter(Boolean);
54
55
  }
@@ -63,18 +64,29 @@ export function getContext(id) {
63
64
  return { ...skill, callees, callers };
64
65
  }
65
66
 
66
- 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) {
67
76
  const db = getDb();
77
+ const id = resolveId(db, nameOrId);
68
78
  return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(id).map(r => r.from_skill);
69
79
  }
70
80
 
71
- export function getCallees(id) {
81
+ export function getCallees(nameOrId) {
72
82
  const db = getDb();
83
+ const id = resolveId(db, nameOrId);
73
84
  return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(id).map(r => r.to_skill);
74
85
  }
75
86
 
76
- export function getImpact(id) {
87
+ export function getImpact(nameOrId) {
77
88
  const db = getDb();
89
+ const id = resolveId(db, nameOrId);
78
90
  const visited = new Set();
79
91
  const queue = [id];
80
92
  while (queue.length) {
package/watcher.js CHANGED
@@ -1,17 +1,17 @@
1
1
  import chokidar from 'chokidar';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import fs from 'fs';
4
5
  import { indexFile } from './indexer.js';
5
6
  import { getDb, skillId } from './db.js';
6
- import { parseSkillFile } from './parser.js';
7
-
8
- const SOURCES = [
9
- { dir: path.join(os.homedir(), '.claude', 'skills-store'), source: 'skills-store' },
10
- { dir: path.join(os.homedir(), '.claude', 'skills'), source: 'skills' },
11
- ];
7
+ import { loadConfig } from './config.js';
8
+ import matter from 'gray-matter';
12
9
 
13
10
  export function startWatcher() {
14
- const paths = SOURCES.map(s => s.dir);
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
15
 
16
16
  const watcher = chokidar.watch(paths, {
17
17
  ignored: /[/\\]\./,
@@ -20,40 +20,51 @@ export function startWatcher() {
20
20
  awaitWriteFinish: { stabilityThreshold: 500 },
21
21
  });
22
22
 
23
- watcher.on('add', filePath => reindex(filePath));
24
- watcher.on('change', filePath => reindex(filePath));
25
- watcher.on('unlink', filePath => remove(filePath));
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
26
 
27
27
  console.error('[PromptGraph] Watcher started');
28
28
  }
29
29
 
30
- function getSource(filePath) {
31
- for (const { dir, source } of SOURCES) {
30
+ function getSource(filePath, config) {
31
+ for (const { dir, source } of config.sources) {
32
32
  if (filePath.startsWith(dir)) return source;
33
33
  }
34
34
  return 'unknown';
35
35
  }
36
36
 
37
- function remove(filePath) {
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) {
38
48
  if (!filePath.endsWith('.md')) return;
39
49
  try {
40
- const source = getSource(filePath);
41
- const name = path.basename(filePath, '.md');
50
+ const source = getSource(filePath, config);
51
+ const name = readName(filePath);
42
52
  const id = skillId(source, name);
43
53
  const db = getDb();
44
54
  db.prepare('DELETE FROM skills WHERE id = ?').run(id);
45
55
  db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(id);
46
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);
47
58
  console.error(`[PromptGraph] Removed: ${id}`);
48
59
  } catch (e) {
49
60
  console.error(`[PromptGraph] Error removing ${filePath}: ${e.message}`);
50
61
  }
51
62
  }
52
63
 
53
- async function reindex(filePath) {
64
+ async function reindex(filePath, config) {
54
65
  if (!filePath.endsWith('.md')) return;
55
66
  try {
56
- await indexFile(filePath, getSource(filePath));
67
+ await indexFile(filePath, getSource(filePath, config));
57
68
  console.error(`[PromptGraph] Reindexed: ${path.basename(filePath)}`);
58
69
  } catch (e) {
59
70
  console.error(`[PromptGraph] Error reindexing ${filePath}: ${e.message}`);