promptgraph-mcp 1.2.0 → 1.4.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
@@ -129,7 +129,7 @@ promptgraph-mcp reindex # Re-index all skills
129
129
  | Skills in context | All 40+ | 1 (router) |
130
130
  | Tokens per session | ~20,000–50,000 | ~150 + 1 skill |
131
131
 
132
- > **Note:** Search is currently linear O(N) over all chunks. Works well up to ~1,000 skills. ANN index (HNSW/LanceDB) planned for larger collections.
132
+ > **Search:** Uses HNSW index (via [vectra](https://github.com/Stevenic/vectra)) for O(log N) approximate nearest neighbor search. Falls back to brute-force on first run before index is built.
133
133
 
134
134
  ## File Structure
135
135
 
package/ann.js ADDED
@@ -0,0 +1,50 @@
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;
9
+
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 });
15
+ }
16
+ return _index;
17
+ }
18
+
19
+ export async function buildAnnIndex() {
20
+ const index = await getIndex();
21
+ await index.createIndex({ version: 1, deleteIfExists: true });
22
+
23
+ const db = getDb();
24
+ const chunks = db.prepare('SELECT skill_id, chunk_index, embedding FROM chunks').all();
25
+
26
+ for (const chunk of chunks) {
27
+ const vec = JSON.parse(chunk.embedding);
28
+ await index.insertItem({
29
+ vector: vec,
30
+ metadata: { skill_id: chunk.skill_id, chunk_index: chunk.chunk_index },
31
+ });
32
+ }
33
+
34
+ console.error(`[PromptGraph] ANN index built: ${chunks.length} chunks`);
35
+ }
36
+
37
+ export async function annSearch(queryVec, topK = 20) {
38
+ try {
39
+ const index = await getIndex();
40
+ if (!await index.isIndexCreated()) return null;
41
+
42
+ const results = await index.queryItems(queryVec, topK);
43
+ return results.map(r => ({
44
+ skill_id: r.item.metadata.skill_id,
45
+ score: r.score,
46
+ }));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
package/chunker.js CHANGED
@@ -1,5 +1,5 @@
1
- const CHUNK_SIZE = 400;
2
- const CHUNK_OVERLAP = 80;
1
+ const CHUNK_SIZE = 800;
2
+ const CHUNK_OVERLAP = 100;
3
3
 
4
4
  export function chunkText(text) {
5
5
  const words = text.split(/\s+/);
package/db.js CHANGED
@@ -17,7 +17,8 @@ export function getDb() {
17
17
  description TEXT,
18
18
  path TEXT NOT NULL,
19
19
  source TEXT NOT NULL,
20
- content TEXT NOT NULL
20
+ content TEXT NOT NULL,
21
+ hash TEXT
21
22
  );
22
23
 
23
24
  CREATE TABLE IF NOT EXISTS chunks (
@@ -34,8 +35,21 @@ export function getDb() {
34
35
  to_skill TEXT NOT NULL,
35
36
  PRIMARY KEY (from_skill, to_skill)
36
37
  );
38
+
39
+ CREATE TABLE IF NOT EXISTS ratings (
40
+ skill_id TEXT PRIMARY KEY,
41
+ uses INTEGER DEFAULT 0,
42
+ success INTEGER DEFAULT 0,
43
+ fail INTEGER DEFAULT 0
44
+ );
37
45
  `);
38
46
 
47
+ // migrate: add hash column if missing
48
+ const cols = db.pragma('table_info(skills)').map(c => c.name);
49
+ if (!cols.includes('hash')) {
50
+ db.exec('ALTER TABLE skills ADD COLUMN hash TEXT');
51
+ }
52
+
39
53
  return db;
40
54
  }
41
55
 
package/embedder.js CHANGED
@@ -1,32 +1,44 @@
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
- }
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
+ const BATCH_SIZE = 64;
7
+
8
+ let model = null;
9
+
10
+ async function getModel() {
11
+ if (!model) {
12
+ model = await FlagEmbedding.init({
13
+ model: EmbeddingModel.BGESmallENV15,
14
+ cacheDir: CACHE_DIR,
15
+ });
16
+ }
17
+ return model;
18
+ }
19
+
20
+ export async function embed(text) {
21
+ const m = await getModel();
22
+ const results = [];
23
+ for await (const batch of m.embed([text])) {
24
+ results.push(...batch);
25
+ }
26
+ return Array.from(results[0]);
27
+ }
28
+
29
+ export async function embedBatch(texts) {
30
+ const m = await getModel();
31
+ const all = [];
32
+ for await (const batch of m.embed(texts)) {
33
+ all.push(...batch);
34
+ }
35
+ return all.map(v => Array.from(v));
36
+ }
37
+
38
+ export function cosineSimilarity(a, b) {
39
+ let dot = 0;
40
+ for (let i = 0; i < a.length; i++) dot += a[i] * b[i];
41
+ return dot;
42
+ }
43
+
44
+ export { BATCH_SIZE };
package/index.js CHANGED
@@ -8,6 +8,7 @@ import { startWatcher } from './watcher.js';
8
8
  import { promptConfig } from './config.js';
9
9
  import { importFromGitHub } from './github-import.js';
10
10
  import { detectPlatforms, PLATFORMS } from './platform.js';
11
+ import { browseMarketplace, installSkill, publishSkill, getTopRated, recordUse, recordSuccess, recordFail } from './marketplace.js';
11
12
 
12
13
  const args = process.argv.slice(2);
13
14
 
@@ -134,6 +135,52 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
134
135
  required: ['name'],
135
136
  },
136
137
  },
138
+ {
139
+ name: 'pg_rate',
140
+ description: 'Record skill usage outcome. Call after applying a skill: outcome="success" if it helped, "fail" if it did not.',
141
+ inputSchema: {
142
+ type: 'object',
143
+ properties: {
144
+ skill_id: { type: 'string' },
145
+ outcome: { type: 'string', enum: ['use', 'success', 'fail'] },
146
+ },
147
+ required: ['skill_id', 'outcome'],
148
+ },
149
+ },
150
+ {
151
+ name: 'pg_top_rated',
152
+ description: 'Get top rated skills by success rate.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: { top_k: { type: 'number' } },
156
+ },
157
+ },
158
+ {
159
+ name: 'pg_marketplace_browse',
160
+ description: 'Browse top skills from the PromptGraph marketplace.',
161
+ inputSchema: {
162
+ type: 'object',
163
+ properties: { top_k: { type: 'number' } },
164
+ },
165
+ },
166
+ {
167
+ name: 'pg_marketplace_install',
168
+ description: 'Install a skill from the marketplace by ID.',
169
+ inputSchema: {
170
+ type: 'object',
171
+ properties: { skill_id: { type: 'string' } },
172
+ required: ['skill_id'],
173
+ },
174
+ },
175
+ {
176
+ name: 'pg_marketplace_publish',
177
+ description: 'Publish a local skill file to the marketplace via GitHub Gist.',
178
+ inputSchema: {
179
+ type: 'object',
180
+ properties: { file_path: { type: 'string' } },
181
+ required: ['file_path'],
182
+ },
183
+ },
137
184
  ],
138
185
  }));
139
186
 
@@ -149,6 +196,16 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
149
196
  case 'pg_callers': result = getCallers(args.name); break;
150
197
  case 'pg_callees': result = getCallees(args.name); break;
151
198
  case 'pg_impact': result = getImpact(args.name); break;
199
+ case 'pg_rate':
200
+ if (args.outcome === 'use') recordUse(args.skill_id);
201
+ else if (args.outcome === 'success') recordSuccess(args.skill_id);
202
+ else if (args.outcome === 'fail') recordFail(args.skill_id);
203
+ result = { ok: true };
204
+ break;
205
+ case 'pg_top_rated': result = getTopRated(args.top_k || 10); break;
206
+ case 'pg_marketplace_browse': result = await browseMarketplace(args.top_k || 20); break;
207
+ case 'pg_marketplace_install': result = await installSkill(args.skill_id); break;
208
+ case 'pg_marketplace_publish': result = await publishSkill(args.file_path); break;
152
209
  default: throw new Error(`Unknown tool: ${name}`);
153
210
  }
154
211
  return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
package/indexer.js CHANGED
@@ -1,37 +1,64 @@
1
1
  import { globSync } from 'glob';
2
+ import { createHash } from 'crypto';
3
+ import fs from 'fs';
2
4
  import { parseSkillFile } from './parser.js';
3
- import { embed } from './embedder.js';
5
+ import { embedBatch, BATCH_SIZE } from './embedder.js';
4
6
  import { getDb, skillId } from './db.js';
5
7
  import { loadConfig } from './config.js';
6
8
  import { chunkText } from './chunker.js';
9
+ import { buildAnnIndex } from './ann.js';
7
10
 
8
- async function indexSkill(db, skill) {
9
- const id = skillId(skill.source, skill.name);
11
+ function fileHash(filePath) {
12
+ const content = fs.readFileSync(filePath);
13
+ return createHash('md5').update(content).digest('hex');
14
+ }
10
15
 
11
- db.prepare(`
12
- INSERT INTO skills (id, name, description, path, source, content)
13
- VALUES (@id, @name, @description, @path, @source, @content)
16
+ async function indexBatch(db, skills) {
17
+ const upsertSkill = db.prepare(`
18
+ INSERT INTO skills (id, name, description, path, source, content, hash)
19
+ VALUES (@id, @name, @description, @path, @source, @content, @hash)
14
20
  ON CONFLICT(id) DO UPDATE SET
15
21
  name = excluded.name,
16
22
  description = excluded.description,
17
23
  path = excluded.path,
18
- content = excluded.content
19
- `).run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content });
20
-
21
- db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(id);
24
+ content = excluded.content,
25
+ hash = excluded.hash
26
+ `);
27
+ const deleteChunks = db.prepare('DELETE FROM chunks WHERE skill_id = ?');
28
+ const deleteEdges = db.prepare('DELETE FROM edges WHERE from_skill = ?');
29
+ const upsertChunk = db.prepare('INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)');
30
+ const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
22
31
 
23
- const chunks = chunkText(skill.name + ' ' + skill.description + '\n' + skill.content);
24
- const upsertChunk = db.prepare(`INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)`);
25
- for (let i = 0; i < chunks.length; i++) {
26
- const vec = await embed(chunks[i]);
27
- upsertChunk.run(id, i, chunks[i], JSON.stringify(vec));
32
+ // collect all chunks across skills in batch
33
+ const allChunks = [];
34
+ for (const skill of skills) {
35
+ const id = skillId(skill.source, skill.name);
36
+ const chunks = chunkText(skill.name + ' ' + skill.description + '\n' + skill.content);
37
+ for (let i = 0; i < chunks.length; i++) {
38
+ allChunks.push({ id, skill, chunkIndex: i, text: chunks[i] });
39
+ }
28
40
  }
29
41
 
30
- db.prepare('DELETE FROM edges WHERE from_skill = ?').run(id);
31
- const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
32
- for (const called of skill.calls) {
33
- upsertEdge.run(id, called);
34
- }
42
+ // embed all chunks in one batch call
43
+ const texts = allChunks.map(c => c.text);
44
+ const embeddings = await embedBatch(texts);
45
+
46
+ const txn = db.transaction(() => {
47
+ for (const skill of skills) {
48
+ const id = skillId(skill.source, skill.name);
49
+ upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null });
50
+ deleteChunks.run(id);
51
+ deleteEdges.run(id);
52
+ for (const called of skill.calls) {
53
+ upsertEdge.run(id, called);
54
+ }
55
+ }
56
+ for (let i = 0; i < allChunks.length; i++) {
57
+ const { id, chunkIndex, text } = allChunks[i];
58
+ upsertChunk.run(id, chunkIndex, text, JSON.stringify(embeddings[i]));
59
+ }
60
+ });
61
+ txn();
35
62
  }
36
63
 
37
64
  export async function indexAll() {
@@ -39,25 +66,67 @@ export async function indexAll() {
39
66
  const db = getDb();
40
67
  db.prepare('DELETE FROM edges').run();
41
68
 
42
- let count = 0;
69
+ // pre-count total files
70
+ let total = 0;
71
+ const allFiles = [];
43
72
  for (const { dir, source } of config.sources) {
44
73
  const files = globSync(`${dir}/**/*.md`);
45
- for (const file of files) {
46
- try {
47
- const skill = parseSkillFile(file, source);
48
- await indexSkill(db, skill);
74
+ files.forEach(f => allFiles.push({ file: f, source }));
75
+ total += files.length;
76
+ }
77
+ console.log(` Total files: ${total}`);
78
+
79
+ let count = 0;
80
+ let errors = 0;
81
+ let batch = [];
82
+ const start = Date.now();
83
+
84
+ const getHash = db.prepare('SELECT hash FROM skills WHERE id = ?');
85
+
86
+ let skipped = 0;
87
+ for (const { file, source } of allFiles) {
88
+ try {
89
+ const hash = fileHash(file);
90
+ const parsed = parseSkillFile(file, source);
91
+ const id = skillId(source, parsed.name);
92
+ const existing = getHash.get(id);
93
+ if (existing?.hash === hash) {
94
+ skipped++;
49
95
  count++;
50
- process.stdout.write(`\r Indexed: ${count} skills`);
51
- } catch (e) {
52
- console.error(`\n Error indexing ${file}: ${e.message}`);
96
+ if (count % 100 === 0) {
97
+ const pct = Math.round(count / total * 100);
98
+ process.stdout.write(`\r [${pct}%] ${count}/${total} | skipped: ${skipped} | errors: ${errors} `);
99
+ }
100
+ continue;
101
+ }
102
+ const skill = { ...parsed, hash };
103
+ batch.push(skill);
104
+ if (batch.length >= BATCH_SIZE) {
105
+ await indexBatch(db, batch);
106
+ count += batch.length;
107
+ batch = [];
108
+ const pct = Math.round(count / total * 100);
109
+ const elapsed = ((Date.now() - start) / 1000).toFixed(0);
110
+ const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
111
+ process.stdout.write(`\r [${pct}%] ${count}/${total} skills | ${elapsed}s elapsed | ETA: ${eta}s | errors: ${errors} `);
53
112
  }
113
+ } catch (e) {
114
+ errors++;
54
115
  }
55
116
  }
117
+
118
+ if (batch.length > 0) {
119
+ await indexBatch(db, batch);
120
+ count += batch.length;
121
+ }
122
+
123
+ process.stdout.write('\r Building ANN index...');
124
+ await buildAnnIndex();
56
125
  console.log(`\n Done. ${count} skills indexed.`);
57
126
  }
58
127
 
59
128
  export async function indexFile(filePath, source) {
60
129
  const db = getDb();
61
130
  const skill = parseSkillFile(filePath, source);
62
- await indexSkill(db, skill);
131
+ await indexBatch(db, [skill]);
63
132
  }
package/marketplace.js ADDED
@@ -0,0 +1,104 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { execSync } from 'child_process';
5
+ import { getDb } from './db.js';
6
+
7
+ const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
8
+ const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills-store', 'marketplace');
9
+
10
+ export async function browseMarketplace(topK = 20) {
11
+ try {
12
+ const res = await fetch(REGISTRY_URL);
13
+ const registry = await res.json();
14
+ return registry.skills
15
+ .sort((a, b) => (b.stars || 0) - (a.stars || 0))
16
+ .slice(0, topK);
17
+ } catch {
18
+ return { error: 'Registry unavailable. Check https://github.com/NeiP4n/promptgraph-registry' };
19
+ }
20
+ }
21
+
22
+ export async function installSkill(skillId) {
23
+ try {
24
+ const res = await fetch(REGISTRY_URL);
25
+ const registry = await res.json();
26
+ const skill = registry.skills.find(s => s.id === skillId);
27
+ if (!skill) return { error: `Skill "${skillId}" not found in registry` };
28
+
29
+ fs.mkdirSync(SKILLS_DIR, { recursive: true });
30
+ const dest = path.join(SKILLS_DIR, `${skillId}.md`);
31
+
32
+ const content = await fetch(skill.raw_url);
33
+ const text = await content.text();
34
+ fs.writeFileSync(dest, text);
35
+
36
+ return { success: true, path: dest, name: skill.name };
37
+ } catch (e) {
38
+ return { error: e.message };
39
+ }
40
+ }
41
+
42
+ export async function publishSkill(filePath) {
43
+ if (!fs.existsSync(filePath)) return { error: `File not found: ${filePath}` };
44
+
45
+ const content = fs.readFileSync(filePath, 'utf8');
46
+ const name = path.basename(filePath, '.md');
47
+
48
+ try {
49
+ const result = execSync(
50
+ `gh gist create "${filePath}" --desc "PromptGraph skill: ${name}" --public`,
51
+ { encoding: 'utf8' }
52
+ ).trim();
53
+ return {
54
+ success: true,
55
+ url: result,
56
+ message: `Published! Submit to registry: https://github.com/NeiP4n/promptgraph-registry/issues/new`,
57
+ };
58
+ } catch {
59
+ return { error: 'gh CLI not found or not authenticated. Run: gh auth login' };
60
+ }
61
+ }
62
+
63
+ export function getTopRated(topK = 10) {
64
+ const db = getDb();
65
+ return db.prepare(`
66
+ SELECT s.id, s.name, s.description, s.source,
67
+ r.uses, r.success, r.fail,
68
+ CASE WHEN (r.success + r.fail) > 0
69
+ THEN ROUND(CAST(r.success AS FLOAT) / (r.success + r.fail), 2)
70
+ ELSE NULL END as rating
71
+ FROM skills s
72
+ LEFT JOIN ratings r ON s.id = r.skill_id
73
+ WHERE r.uses > 0
74
+ ORDER BY rating DESC, r.uses DESC
75
+ LIMIT ?
76
+ `).all(topK);
77
+ }
78
+
79
+ export function recordUse(skillId) {
80
+ const db = getDb();
81
+ db.prepare(`
82
+ INSERT INTO ratings (skill_id, uses, success, fail)
83
+ VALUES (?, 1, 0, 0)
84
+ ON CONFLICT(skill_id) DO UPDATE SET uses = uses + 1
85
+ `).run(skillId);
86
+ }
87
+
88
+ export function recordSuccess(skillId) {
89
+ const db = getDb();
90
+ db.prepare(`
91
+ INSERT INTO ratings (skill_id, uses, success, fail)
92
+ VALUES (?, 0, 1, 0)
93
+ ON CONFLICT(skill_id) DO UPDATE SET success = success + 1
94
+ `).run(skillId);
95
+ }
96
+
97
+ export function recordFail(skillId) {
98
+ const db = getDb();
99
+ db.prepare(`
100
+ INSERT INTO ratings (skill_id, uses, success, fail)
101
+ VALUES (?, 0, 0, 1)
102
+ ON CONFLICT(skill_id) DO UPDATE SET fail = fail + 1
103
+ `).run(skillId);
104
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "chokidar": "^5.0.0",
38
38
  "fastembed": "^2.1.0",
39
39
  "glob": "^13.0.6",
40
- "gray-matter": "^4.0.3"
40
+ "gray-matter": "^4.0.3",
41
+ "vectra": "^0.15.0"
41
42
  }
42
43
  }
package/search.js CHANGED
@@ -1,34 +1,61 @@
1
1
  import { embed, cosineSimilarity } from './embedder.js';
2
2
  import { getDb } from './db.js';
3
+ import { annSearch } from './ann.js';
4
+
5
+ function applyRatingBoost(db, id, score) {
6
+ const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
7
+ if (r && (r.success + r.fail) > 3) {
8
+ const rating = r.success / (r.success + r.fail);
9
+ return score * (0.85 + 0.15 * rating);
10
+ }
11
+ return score;
12
+ }
3
13
 
4
14
  export async function search(query, topK = 5) {
5
15
  const db = getDb();
6
16
  const queryVec = await embed(query);
7
17
 
8
- const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
18
+ // Try ANN index first (fast, O(log N))
19
+ const annResults = await annSearch(queryVec, topK * 4);
9
20
 
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
+ return [...bestBySkill.entries()]
28
+ .sort((a, b) => b[1] - a[1])
29
+ .slice(0, topK)
30
+ .map(([id, score]) => {
31
+ 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
+ })
34
+ .filter(Boolean);
35
+ }
36
+
37
+ // Fallback: brute force (used before first reindex)
38
+ const chunks = db.prepare('SELECT skill_id, embedding FROM chunks').all();
10
39
  const bestBySkill = new Map();
11
40
  for (const chunk of chunks) {
12
41
  const score = cosineSimilarity(queryVec, JSON.parse(chunk.embedding));
13
42
  const prev = bestBySkill.get(chunk.skill_id);
14
43
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
15
44
  }
16
-
17
- const topIds = [...bestBySkill.entries()]
45
+ return [...bestBySkill.entries()]
18
46
  .sort((a, b) => b[1] - a[1])
19
47
  .slice(0, topK)
20
- .map(([id]) => id);
21
-
22
- return topIds.map(id => {
23
- const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
24
- return { ...skill, score: bestBySkill.get(id) };
25
- });
48
+ .map(([id, score]) => {
49
+ const skill = db.prepare('SELECT id, name, description, path, source FROM skills WHERE id = ?').get(id);
50
+ return skill ? { ...skill, score } : null;
51
+ })
52
+ .filter(Boolean);
26
53
  }
27
54
 
28
55
  export function getContext(id) {
29
56
  const db = getDb();
30
57
  const skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(id)
31
- || db.prepare("SELECT * FROM skills WHERE name = ? ORDER BY id LIMIT 1").get(id);
58
+ || db.prepare('SELECT * FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(id);
32
59
  if (!skill) return null;
33
60
  const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(skill.id).map(r => r.to_skill);
34
61
  const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(skill.id).map(r => r.from_skill);