promptgraph-mcp 1.5.26 → 1.8.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 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 dumps every file in `~/.claude/commands/` into the system prompt — every session, every message. At 40+ skills that's **20,000–50,000 wasted tokens before you say a word.**
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. When you ask something, Claude calls `pg_search` → gets the right skill path → reads only that file.
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 loaded~40,000 tokens wasted
20
- After: 1 router + 1 match ~300 tokens total
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 { LocalIndex } from 'vectra';
2
- import path from 'path';
3
- import os from 'os';
4
- import { getDb } from './db.js';
5
-
6
- const INDEX_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'hnsw-index');
7
-
8
- let _index = null;
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
- async function getIndex() {
11
- if (_index) return _index;
12
- _index = new LocalIndex(INDEX_PATH);
13
- if (!await _index.isIndexCreated()) {
14
- await _index.createIndex({ version: 1, deleteIfExists: true });
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 _index;
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
- const index = await getIndex();
21
- await index.createIndex({ version: 1, deleteIfExists: true });
22
-
33
+ _cache = null;
34
+ _cacheChunkCount = -1;
23
35
  const db = getDb();
24
- const chunks = db.prepare('SELECT skill_id, chunk_index, embedding FROM chunks').all();
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 index = await getIndex();
50
- if (!await index.isIndexCreated()) return null;
51
- const items = await index.listItems();
52
- if (!items || items.length === 0) return null;
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
- const results = await index.queryItems(queryVec, topK);
55
- return results.map(r => ({
56
- skill_id: r.item.metadata.skill_id,
57
- score: r.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
- const words = text.split(/\s+/);
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
- while (i < words.length) {
10
- const chunk = words.slice(i, i + CHUNK_SIZE).join(' ');
11
- chunks.push(chunk);
12
- if (i + CHUNK_SIZE >= words.length) break;
13
- i += CHUNK_SIZE - CHUNK_OVERLAP;
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 CONFIG_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'config.json');
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(os.homedir(), '.claude', '.promptgraph', 'promptgraph.db');
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 TEXT NOT NULL,
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', 'search', 'help', '--help', '-h']);
15
+ const KNOWN_COMMANDS = new Set(['init', 'reindex', 'update', 'import', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h']);
16
16
 
17
17
  function showHelp() {
18
18
  console.log(
@@ -31,13 +31,14 @@ function showHelp() {
31
31
  ['marketplace [page]', 'Browse the community skill registry'],
32
32
  ['validate <file.md>', 'Validate a skill before publishing'],
33
33
  ['doctor', 'Clean orphaned chunks/edges/ratings'],
34
+ ['update', 'Update to the latest version from npm'],
34
35
  ['setup <platform>', 'Register MCP in platform config'],
35
36
  ['help', 'Show this help'],
36
37
  ];
37
38
  for (const [cmd, desc] of cmds) {
38
39
  console.log(' ' + chalk.hex('#7C3AED')((bin + ' ' + cmd).padEnd(28)) + chalk.gray(desc));
39
40
  }
40
- console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf'));
41
+ console.log(chalk.gray('\nPlatforms: claude-code, claude-desktop, cline, codex, cursor, windsurf, opencode'));
41
42
  console.log(chalk.gray('\n github.com/NeiP4n/promptgraph · npmjs.com/package/promptgraph-mcp\n'));
42
43
  }
43
44
 
@@ -116,8 +117,14 @@ if (args[0] === 'marketplace' && (args[1] === 'bundles' || args[1] === 'bundle')
116
117
  console.log();
117
118
  });
118
119
 
119
- console.log(' ' + chalk.gray('─'.repeat(54)));
120
- console.log(' ' + chalk.gray('Installs all skills in the set. Run ') + chalk.cyan(`${bin} marketplace`) + chalk.gray(' for single skills.'));
120
+ console.log(
121
+ boxen(
122
+ chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
123
+ chalk.dim('browse skills ') + chalk.cyan(`${bin} marketplace`) + '\n' +
124
+ chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>'),
125
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
126
+ )
127
+ );
121
128
  console.log();
122
129
  process.exit(0);
123
130
  }
@@ -190,10 +197,21 @@ if (args[0] === 'marketplace') {
190
197
  if (page > 1) nav.push(chalk.dim('‹ ') + chalk.cyan(`${bin} marketplace ${page - 1}`));
191
198
  if (page < totalPages) nav.push(chalk.cyan(`${bin} marketplace ${page + 1}`) + chalk.dim(' ›'));
192
199
  console.log(' ' + nav.join(' '));
200
+ console.log();
193
201
  }
194
- console.log(' ' + chalk.dim('install ') + chalk.cyan('tell your AI:') + ' ' + chalk.white('install ') + chalk.hex('#A78BFA')(slice[0].code || slice[0].id));
195
- console.log(' ' + chalk.dim('bundles ') + chalk.cyan(`${bin} marketplace bundles`));
196
- console.log(' ' + chalk.dim('publish ') + chalk.cyan('tell your AI:') + ' ' + chalk.white('/pg-publish <file>'));
202
+
203
+ const exCode = slice[0]?.code || slice[0]?.id || 'pg-xxxxxx';
204
+ console.log(
205
+ boxen(
206
+ chalk.dim('install skill ') + chalk.white('install ') + chalk.hex('#A78BFA')(exCode) + '\n' +
207
+ chalk.dim('install bundle ') + chalk.white('install bundle ') + chalk.hex('#A78BFA')('engineering-essentials') + '\n' +
208
+ chalk.dim('from GitHub ') + chalk.white('install ') + chalk.hex('#A78BFA')('https://github.com/owner/repo/blob/main/skill.md') + '\n' +
209
+ chalk.dim('publish skill ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<file.md>') + '\n' +
210
+ chalk.dim('publish bundle ') + chalk.white('/pg-publish ') + chalk.hex('#A78BFA')('<bundle.json>') + '\n' +
211
+ chalk.dim('view bundles ') + chalk.cyan(`${bin} marketplace bundles`),
212
+ { padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
213
+ )
214
+ );
197
215
  console.log();
198
216
  process.exit(0);
199
217
  }
@@ -261,6 +279,24 @@ if (args[0] === 'setup') {
261
279
  if (args[0] === 'init') {
262
280
  const { promptConfig } = await import('./config.js');
263
281
  const { indexAll } = await import('./indexer.js');
282
+ const os = await import('os');
283
+ const fs = await import('fs');
284
+ const path = await import('path');
285
+ const commandsDir = path.default.join(os.default.homedir(), '.claude', 'commands');
286
+ if (!fs.default.existsSync(commandsDir)) {
287
+ console.log(chalk.yellow('⚠') + ' ' + chalk.gray('~/.claude/commands/ not found — is Claude Code installed?'));
288
+ console.log(chalk.gray(' Install from: https://claude.ai/download\n'));
289
+ }
290
+ if (!args.includes('--yes') && !args.includes('-y')) {
291
+ const readline = await import('readline');
292
+ const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
293
+ const answer = await new Promise(r => rl.question(
294
+ chalk.yellow(' ⚠') + chalk.gray(' First run downloads ~23 MB embedding model (BGE-Small-EN).\n Proceed? [Y/n] '), r
295
+ ));
296
+ rl.close();
297
+ if (answer.trim().toLowerCase() === 'n') { info('Aborted.'); process.exit(0); }
298
+ }
299
+ console.log(chalk.gray('\n Downloading embedding model (~23 MB, one-time)...\n'));
264
300
  const config = await promptConfig();
265
301
  await indexAll();
266
302
  console.log();
@@ -274,6 +310,43 @@ if (args[0] === 'init') {
274
310
  process.exit(0);
275
311
  }
276
312
 
313
+ if (args[0] === 'update') {
314
+ const { spawnSync } = await import('child_process');
315
+ const { createRequire } = await import('module');
316
+ const req = createRequire(import.meta.url);
317
+ const currentVersion = req('./package.json').version;
318
+
319
+ // Check latest version on npm
320
+ const spin = (await import('./cli.js')).spinner('Checking latest version...');
321
+ spin.start();
322
+ let latest = null;
323
+ try {
324
+ const r = spawnSync('npm', ['view', 'promptgraph-mcp', 'version'], { encoding: 'utf8' });
325
+ latest = r.stdout?.trim();
326
+ } catch {}
327
+ spin.stop();
328
+
329
+ if (!latest) { error('Could not reach npm registry. Check your network.'); process.exit(1); }
330
+ if (latest === currentVersion) {
331
+ success(`Already on latest version ${chalk.white.bold('v' + currentVersion)}`);
332
+ process.exit(0);
333
+ }
334
+
335
+ info(`Current: ${chalk.gray('v' + currentVersion)} → Latest: ${chalk.white.bold('v' + latest)}`);
336
+ const updateSpin = (await import('./cli.js')).spinner(`Installing promptgraph-mcp@${latest}...`);
337
+ updateSpin.start();
338
+ const result = spawnSync('npm', ['install', '-g', `promptgraph-mcp@${latest}`], { encoding: 'utf8', stdio: 'pipe' });
339
+ updateSpin.stop();
340
+
341
+ if (result.status !== 0) {
342
+ error('Update failed:');
343
+ console.log(chalk.gray(result.stderr || result.stdout));
344
+ process.exit(1);
345
+ }
346
+ success(`Updated to ${chalk.white.bold('v' + latest)}`);
347
+ process.exit(0);
348
+ }
349
+
277
350
  if (args[0] === 'reindex') {
278
351
  const { indexAll } = await import('./indexer.js');
279
352
  await indexAll();
@@ -285,8 +358,9 @@ const { Server } = await import('@modelcontextprotocol/sdk/server/index.js');
285
358
  const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
286
359
  const { CallToolRequestSchema, ListToolsRequestSchema } = await import('@modelcontextprotocol/sdk/types.js');
287
360
  const { search, getContext, getCallers, getCallees, getImpact, listAll } = await import('./search.js');
361
+ const { loadConfig: _loadConfig, saveConfig: _saveConfig } = await import('./config.js');
288
362
  const { startWatcher } = await import('./watcher.js');
289
- const { browseMarketplace, installSkill, publishSkill, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
363
+ const { browseMarketplace, installSkill, installSkillFromUrl, publishSkill, publishBundle, getTopRated, recordUse, recordSuccess, recordFail, browseBundles, installBundle } = await import('./marketplace.js');
290
364
 
291
365
  const server = new Server(
292
366
  { name: 'promptgraph', version: '1.0.0' },
@@ -408,6 +482,45 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
408
482
  required: ['bundle_id'],
409
483
  },
410
484
  },
485
+ {
486
+ name: 'pg_config',
487
+ description: 'Get or update PromptGraph config. action="get" returns current sources. action="add_source" adds a directory.',
488
+ inputSchema: {
489
+ type: 'object',
490
+ properties: {
491
+ action: { type: 'string', enum: ['get', 'add_source', 'remove_source'] },
492
+ dir: { type: 'string', description: 'Directory path (for add_source)' },
493
+ source: { type: 'string', description: 'Source label (for add_source/remove_source)' },
494
+ },
495
+ required: ['action'],
496
+ },
497
+ },
498
+ {
499
+ name: 'pg_install_url',
500
+ description: 'Install a skill directly from a GitHub URL or raw URL. Validates before saving.',
501
+ inputSchema: {
502
+ type: 'object',
503
+ properties: { url: { type: 'string', description: 'GitHub blob URL or raw URL of a .md skill file' } },
504
+ required: ['url'],
505
+ },
506
+ },
507
+ {
508
+ name: 'pg_bundle_publish',
509
+ description: 'Publish a bundle definition to GitHub Gist and get a registry submission link. Pass a JSON object or path to a .json file.',
510
+ inputSchema: {
511
+ type: 'object',
512
+ properties: {
513
+ bundle: {
514
+ description: 'Bundle definition object { id, name, description, skills[], tags[] } OR file path to .json',
515
+ oneOf: [
516
+ { type: 'object' },
517
+ { type: 'string' },
518
+ ],
519
+ },
520
+ },
521
+ required: ['bundle'],
522
+ },
523
+ },
411
524
  ],
412
525
  }));
413
526
 
@@ -435,6 +548,30 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
435
548
  case 'pg_marketplace_publish': result = await publishSkill(args.file_path); break;
436
549
  case 'pg_bundle_browse': result = await browseBundles(args.top_k || 20); break;
437
550
  case 'pg_bundle_install': result = await installBundle(args.bundle_id); break;
551
+ case 'pg_install_url': result = await installSkillFromUrl(args.url); break;
552
+ case 'pg_bundle_publish': result = await publishBundle(args.bundle); break;
553
+ case 'pg_config': {
554
+ const cfg = _loadConfig();
555
+ if (args.action === 'get') {
556
+ result = { sources: cfg.sources };
557
+ } else if (args.action === 'add_source') {
558
+ if (!args.dir || !args.source) throw new Error('dir and source required for add_source');
559
+ if (cfg.sources.find(s => s.source === args.source)) throw new Error(`Source "${args.source}" already exists`);
560
+ cfg.sources.push({ dir: args.dir, source: args.source });
561
+ _saveConfig(cfg);
562
+ result = { ok: true, sources: cfg.sources };
563
+ } else if (args.action === 'remove_source') {
564
+ if (!args.source) throw new Error('source required for remove_source');
565
+ const before = cfg.sources.length;
566
+ cfg.sources = cfg.sources.filter(s => s.source !== args.source);
567
+ if (cfg.sources.length === before) throw new Error(`Source "${args.source}" not found`);
568
+ _saveConfig(cfg);
569
+ result = { ok: true, sources: cfg.sources };
570
+ } else {
571
+ throw new Error(`Unknown action: ${args.action}`);
572
+ }
573
+ break;
574
+ }
438
575
  default: throw new Error(`Unknown tool: ${name}`);
439
576
  }
440
577
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
@@ -448,3 +585,11 @@ startWatcher();
448
585
  const transport = new StdioServerTransport();
449
586
  await server.connect(transport);
450
587
  console.error('[PromptGraph] MCP server running');
588
+
589
+ const { getDb: _getDb } = await import('./db.js');
590
+ const shutdown = () => {
591
+ try { _getDb().close(); } catch {}
592
+ process.exit(0);
593
+ };
594
+ process.on('SIGTERM', shutdown);
595
+ 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, JSON.stringify(embeddings[i]));
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
- // Remove stale record if the file was previously indexed but now fails to parse
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);