promptgraph-mcp 2.8.1 → 2.8.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 +205 -205
- package/ann.js +33 -33
- package/api.js +202 -202
- package/bundle-counts.js +111 -111
- package/chunker.js +28 -28
- package/cli.js +115 -115
- package/commands/bundle.js +150 -150
- package/commands/doctor.js +15 -15
- package/commands/import.js +7 -7
- package/commands/init.js +37 -37
- package/commands/marketplace.js +146 -146
- package/commands/reindex.js +10 -10
- package/commands/search.js +55 -55
- package/commands/setup.js +19 -19
- package/commands/status.js +110 -110
- package/commands/train.js +18 -18
- package/commands/update.js +49 -49
- package/commands/validate.js +63 -63
- package/config.js +72 -72
- package/db.js +157 -157
- package/doctor.js +48 -48
- package/embedder.js +54 -54
- package/github-import.js +750 -745
- package/indexer.js +310 -310
- package/package.json +61 -61
- package/parser.js +69 -69
- package/pg-hook.js +70 -70
- package/platform.js +120 -120
- package/search.js +216 -216
- package/src/filter/classifier.js +88 -88
- package/src/filter/hard-filter.js +62 -62
- package/src/filter/train.js +66 -66
- package/src/reranker/reranker.js +92 -92
- package/src/store/flat-store.js +61 -61
- package/src/store/hnsw-store.js +187 -187
- package/src/store/index.js +19 -19
- package/src/store/vector-store.js +9 -9
- package/src/utils/rate-limiter.js +33 -33
- package/tui.js +418 -418
- package/validate-repo-action.js +139 -139
- package/watcher.js +84 -84
package/indexer.js
CHANGED
|
@@ -1,310 +1,310 @@
|
|
|
1
|
-
import { globSync } from 'glob';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
|
-
import fs from 'fs';
|
|
4
|
-
import path from 'path';
|
|
5
|
-
import { parseSkillFile, isSkillFile, filterWithClassifier } from './parser.js';
|
|
6
|
-
import { embedBatch, cosineSimilarity } from './embedder.js';
|
|
7
|
-
import { BATCH_SIZE } from './config.js';
|
|
8
|
-
import { getDb, skillId, vecToBlob } from './db.js';
|
|
9
|
-
import { loadConfig } from './config.js';
|
|
10
|
-
import { chunkText } from './chunker.js';
|
|
11
|
-
import { buildAnnIndex } from './ann.js';
|
|
12
|
-
import { progress, progressDone, success, info, spinner } from './cli.js';
|
|
13
|
-
import chalk from 'chalk';
|
|
14
|
-
|
|
15
|
-
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB per file
|
|
16
|
-
const MAX_FILE_COUNT = 100000; // 100k files per reindex
|
|
17
|
-
|
|
18
|
-
function sanitizePath(filePath) {
|
|
19
|
-
return path.resolve(filePath);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function indexBatch(db, skills, { fast = false } = {}) {
|
|
23
|
-
const upsertSkill = db.prepare(`
|
|
24
|
-
INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
|
|
25
|
-
VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
|
|
26
|
-
ON CONFLICT(id) DO UPDATE SET
|
|
27
|
-
name = excluded.name,
|
|
28
|
-
description = excluded.description,
|
|
29
|
-
path = excluded.path,
|
|
30
|
-
content = excluded.content,
|
|
31
|
-
hash = excluded.hash,
|
|
32
|
-
version = excluded.version,
|
|
33
|
-
author = excluded.author,
|
|
34
|
-
license = excluded.license,
|
|
35
|
-
updated_at = excluded.updated_at,
|
|
36
|
-
downloads = excluded.downloads,
|
|
37
|
-
verified = excluded.verified
|
|
38
|
-
`);
|
|
39
|
-
const deleteChunks = db.prepare('DELETE FROM chunks WHERE skill_id = ?');
|
|
40
|
-
const deleteEdges = db.prepare('DELETE FROM edges WHERE from_skill = ?');
|
|
41
|
-
const upsertChunk = db.prepare('INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)');
|
|
42
|
-
const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
|
|
43
|
-
const upsertFts = db.prepare(`INSERT OR REPLACE INTO skills_fts(id, name, description, content) VALUES (?, ?, ?, ?)`);
|
|
44
|
-
|
|
45
|
-
const allChunks = [];
|
|
46
|
-
if (!fast) {
|
|
47
|
-
for (const skill of skills) {
|
|
48
|
-
const id = skillId(skill.source, skill.name);
|
|
49
|
-
const chunks = chunkText(skill.name + ' ' + skill.description + '\n' + skill.content);
|
|
50
|
-
for (let i = 0; i < chunks.length; i++) {
|
|
51
|
-
allChunks.push({ id, skill, chunkIndex: i, text: chunks[i] });
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
let embeddings = [];
|
|
57
|
-
if (!fast && allChunks.length) {
|
|
58
|
-
const texts = allChunks.map(c => c.text);
|
|
59
|
-
process.stdout.write(` Embedding ${texts.length} chunks...`);
|
|
60
|
-
embeddings = await embedBatch(texts);
|
|
61
|
-
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// pass 1: upsert all skills + chunks (no edges yet)
|
|
65
|
-
db.transaction(() => {
|
|
66
|
-
for (const skill of skills) {
|
|
67
|
-
const id = skillId(skill.source, skill.name);
|
|
68
|
-
upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null, version: skill.version || null, author: skill.author || null, license: skill.license || null, updated_at: skill.updated_at || null, downloads: skill.downloads ?? 0, verified: skill.verified ? 1 : 0 });
|
|
69
|
-
upsertFts.run(id, skill.name, skill.description || '', skill.content || '');
|
|
70
|
-
if (!fast) {
|
|
71
|
-
deleteChunks.run(id);
|
|
72
|
-
deleteEdges.run(id);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
if (!fast) {
|
|
76
|
-
for (let i = 0; i < allChunks.length; i++) {
|
|
77
|
-
const { id, chunkIndex, text } = allChunks[i];
|
|
78
|
-
upsertChunk.run(id, chunkIndex, text, vecToBlob(embeddings[i]));
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
})();
|
|
82
|
-
|
|
83
|
-
// pass 2: resolve edges after all skills in batch are committed
|
|
84
|
-
const resolveSameSource = db.prepare("SELECT id FROM skills WHERE name = ? AND source = ? LIMIT 1");
|
|
85
|
-
const resolveAny = db.prepare("SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1");
|
|
86
|
-
db.transaction(() => {
|
|
87
|
-
for (const skill of skills) {
|
|
88
|
-
const id = skillId(skill.source, skill.name);
|
|
89
|
-
for (const calledName of skill.calls) {
|
|
90
|
-
// prefer a skill in the same source, fall back to any, then bare name
|
|
91
|
-
const same = resolveSameSource.get(calledName, skill.source);
|
|
92
|
-
const resolved = same || resolveAny.get(calledName);
|
|
93
|
-
upsertEdge.run(id, resolved ? resolved.id : calledName);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
})();
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export async function indexAll({ fast = false } = {}) {
|
|
100
|
-
const config = loadConfig();
|
|
101
|
-
const db = getDb();
|
|
102
|
-
|
|
103
|
-
// collect all files on disk — use longest-matching source for files in subdirs
|
|
104
|
-
// (e.g. skills-store/marketplace/*.md → 'marketplace', not 'skills-store')
|
|
105
|
-
const normalizedSources = config.sources.map(s => ({
|
|
106
|
-
...s,
|
|
107
|
-
normDir: path.resolve(s.dir),
|
|
108
|
-
})).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
|
|
109
|
-
|
|
110
|
-
const seenFiles = new Set();
|
|
111
|
-
const allFiles = [];
|
|
112
|
-
for (const { dir, source } of normalizedSources) {
|
|
113
|
-
const files = globSync(`${dir}/**/*.md`);
|
|
114
|
-
for (const f of files) {
|
|
115
|
-
const norm = sanitizePath(f);
|
|
116
|
-
if (!seenFiles.has(norm)) {
|
|
117
|
-
seenFiles.add(norm);
|
|
118
|
-
allFiles.push({ file: norm, source });
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
if (allFiles.length > MAX_FILE_COUNT) {
|
|
122
|
-
info(chalk.yellow(`Reached max file count (${MAX_FILE_COUNT}) — truncating`));
|
|
123
|
-
break;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
const total = allFiles.length;
|
|
127
|
-
info(`Found ${chalk.white.bold(total)} files`);
|
|
128
|
-
|
|
129
|
-
// reconcile: remove skills whose files no longer exist OR whose name changed
|
|
130
|
-
const allDbSkills = db.prepare('SELECT id, path, name, source FROM skills').all();
|
|
131
|
-
const existingPaths = new Set(allFiles.map(f => f.file));
|
|
132
|
-
let removed = 0;
|
|
133
|
-
|
|
134
|
-
// build expected id map from disk
|
|
135
|
-
const expectedIds = new Map();
|
|
136
|
-
for (const { file, source } of allFiles) {
|
|
137
|
-
try {
|
|
138
|
-
const parsed = parseSkillFile(file, source);
|
|
139
|
-
expectedIds.set(file, skillId(source, parsed.name));
|
|
140
|
-
} catch {}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
for (const row of allDbSkills) {
|
|
144
|
-
const pathGone = !existingPaths.has(row.path);
|
|
145
|
-
const idChanged = expectedIds.has(row.path) && expectedIds.get(row.path) !== row.id;
|
|
146
|
-
if (pathGone || idChanged) {
|
|
147
|
-
db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
|
|
148
|
-
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
|
|
149
|
-
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
|
|
150
|
-
db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(row.id);
|
|
151
|
-
removed++;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (removed > 0) info(`Removed ${chalk.yellow(removed)} stale/deleted skills`);
|
|
155
|
-
|
|
156
|
-
let count = 0;
|
|
157
|
-
let errors = 0;
|
|
158
|
-
let skipped = 0;
|
|
159
|
-
let classifierRemoved = 0;
|
|
160
|
-
let batch = [];
|
|
161
|
-
const start = Date.now();
|
|
162
|
-
|
|
163
|
-
// Build a path→{hash,id} map from DB for O(1) lookups
|
|
164
|
-
const dbByPath = new Map();
|
|
165
|
-
for (const row of db.prepare('SELECT id, path, hash FROM skills').all()) {
|
|
166
|
-
dbByPath.set(row.path, row);
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
for (const { file, source } of allFiles) {
|
|
170
|
-
try {
|
|
171
|
-
// 1. Read file once (with size gate)
|
|
172
|
-
let raw;
|
|
173
|
-
try {
|
|
174
|
-
const stat = fs.statSync(file);
|
|
175
|
-
if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
|
|
176
|
-
raw = fs.readFileSync(file, 'utf8');
|
|
177
|
-
} catch { skipped++; count++; continue; }
|
|
178
|
-
|
|
179
|
-
// 2. Hash first — cheapest check
|
|
180
|
-
const hash = createHash('md5').update(raw).digest('hex');
|
|
181
|
-
|
|
182
|
-
// 3. If path already in DB with same hash → skip without parsing
|
|
183
|
-
const dbRow = dbByPath.get(file);
|
|
184
|
-
if (dbRow?.hash === hash) {
|
|
185
|
-
skipped++; count++;
|
|
186
|
-
if (count % 200 === 0) {
|
|
187
|
-
const eta = Math.round((total - count) * (Date.now() - start) / count / 1000);
|
|
188
|
-
progress(count, total, { skipped, eta, errors });
|
|
189
|
-
}
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// 4. Only now check if it's a real skill (content already in memory)
|
|
194
|
-
if (!isSkillFile(file, raw)) { skipped++; count++; continue; }
|
|
195
|
-
|
|
196
|
-
const parsed = parseSkillFile(file, source, { raw });
|
|
197
|
-
batch.push({ ...parsed, hash });
|
|
198
|
-
|
|
199
|
-
if (batch.length >= BATCH_SIZE) {
|
|
200
|
-
const filtered = await filterWithClassifier(batch);
|
|
201
|
-
classifierRemoved += batch.length - filtered.length;
|
|
202
|
-
await indexBatch(db, filtered, { fast });
|
|
203
|
-
count += filtered.length;
|
|
204
|
-
batch = [];
|
|
205
|
-
const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
|
|
206
|
-
progress(count, total, { skipped, eta, errors });
|
|
207
|
-
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
208
|
-
}
|
|
209
|
-
} catch (e) {
|
|
210
|
-
errors++;
|
|
211
|
-
console.error(`[PromptGraph] Error indexing ${file}: ${e.message}`);
|
|
212
|
-
try {
|
|
213
|
-
const stale = db.prepare('SELECT id FROM skills WHERE path = ?').get(file);
|
|
214
|
-
if (stale) {
|
|
215
|
-
db.prepare('DELETE FROM skills WHERE id = ?').run(stale.id);
|
|
216
|
-
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(stale.id);
|
|
217
|
-
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(stale.id, stale.id);
|
|
218
|
-
db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(stale.id);
|
|
219
|
-
}
|
|
220
|
-
} catch {}
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
if (batch.length > 0) {
|
|
225
|
-
const filtered = await filterWithClassifier(batch);
|
|
226
|
-
classifierRemoved += batch.length - filtered.length;
|
|
227
|
-
await indexBatch(db, filtered, { fast });
|
|
228
|
-
count += filtered.length;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
progress(total, total, { skipped, errors });
|
|
232
|
-
progressDone();
|
|
233
|
-
if (!fast) {
|
|
234
|
-
const spin = spinner('Building ANN index...');
|
|
235
|
-
spin.start();
|
|
236
|
-
await buildAnnIndex();
|
|
237
|
-
spin.stop();
|
|
238
|
-
}
|
|
239
|
-
const stats = [`${errors} errors`, `${skipped} skipped`, `${removed} removed`];
|
|
240
|
-
if (classifierRemoved > 0) stats.push(`${classifierRemoved} filtered`);
|
|
241
|
-
success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${stats.join(', ')})`)}`);
|
|
242
|
-
if (fast) info(chalk.yellow('Fast mode: keyword search only. Run `pg reindex` for semantic search.'));
|
|
243
|
-
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
244
|
-
info(chalk.gray(`Time: ${elapsed}s`));
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
export async function indexFile(filePath, source) {
|
|
248
|
-
const safe = sanitizePath(filePath);
|
|
249
|
-
const stat = fs.statSync(safe);
|
|
250
|
-
if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large: ${filePath}`);
|
|
251
|
-
const db = getDb();
|
|
252
|
-
const raw = fs.readFileSync(safe, 'utf8');
|
|
253
|
-
const hash = createHash('md5').update(raw).digest('hex');
|
|
254
|
-
const skill = parseSkillFile(safe, source, { raw });
|
|
255
|
-
await indexBatch(db, [{ ...skill, hash }]);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// Index only one source directory — much faster than indexAll after a bundle install
|
|
259
|
-
export async function indexSource(dir, sourceName) {
|
|
260
|
-
const db = getDb();
|
|
261
|
-
const files = globSync(`${dir}/**/*.md`);
|
|
262
|
-
const total = files.length;
|
|
263
|
-
info(`Indexing ${chalk.white.bold(total)} files from ${sourceName}...`);
|
|
264
|
-
|
|
265
|
-
const dbByPath = new Map();
|
|
266
|
-
for (const row of db.prepare('SELECT id, path, hash FROM skills WHERE source = ?').all(sourceName)) {
|
|
267
|
-
dbByPath.set(row.path, row);
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// Remove skills from this source whose files are gone
|
|
271
|
-
const existingPaths = new Set(files.map(f => path.resolve(f)));
|
|
272
|
-
for (const [, row] of dbByPath) {
|
|
273
|
-
if (!existingPaths.has(path.resolve(row.path))) {
|
|
274
|
-
db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
|
|
275
|
-
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
|
|
276
|
-
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
let count = 0, skipped = 0, errors = 0, batch = [];
|
|
281
|
-
const start = Date.now();
|
|
282
|
-
|
|
283
|
-
for (const file of files) {
|
|
284
|
-
try {
|
|
285
|
-
const norm = sanitizePath(file);
|
|
286
|
-
const stat = fs.statSync(norm);
|
|
287
|
-
if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
|
|
288
|
-
const raw = fs.readFileSync(norm, 'utf8');
|
|
289
|
-
const hash = createHash('md5').update(raw).digest('hex');
|
|
290
|
-
const dbRow = dbByPath.get(file);
|
|
291
|
-
if (dbRow?.hash === hash) { skipped++; count++; continue; }
|
|
292
|
-
if (!isSkillFile(file, raw)) { skipped++; count++; continue; }
|
|
293
|
-
const parsed = parseSkillFile(file, sourceName, { raw });
|
|
294
|
-
batch.push({ ...parsed, hash });
|
|
295
|
-
if (batch.length >= BATCH_SIZE) {
|
|
296
|
-
await indexBatch(db, batch);
|
|
297
|
-
count += batch.length; batch = [];
|
|
298
|
-
progress(count, total, { skipped, errors });
|
|
299
|
-
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
300
|
-
}
|
|
301
|
-
} catch (e) { errors++; count++; }
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (batch.length > 0) { await indexBatch(db, batch); count += batch.length; }
|
|
305
|
-
progress(total, total, { skipped, errors });
|
|
306
|
-
progressDone();
|
|
307
|
-
await buildAnnIndex();
|
|
308
|
-
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
309
|
-
success(`Indexed ${chalk.white.bold(count)} skills from ${sourceName} ${chalk.gray(`(${skipped} skipped, ${elapsed}s)`)}`);
|
|
310
|
-
}
|
|
1
|
+
import { globSync } from 'glob';
|
|
2
|
+
import { createHash } from 'crypto';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { parseSkillFile, isSkillFile, filterWithClassifier } from './parser.js';
|
|
6
|
+
import { embedBatch, cosineSimilarity } from './embedder.js';
|
|
7
|
+
import { BATCH_SIZE } from './config.js';
|
|
8
|
+
import { getDb, skillId, vecToBlob } from './db.js';
|
|
9
|
+
import { loadConfig } from './config.js';
|
|
10
|
+
import { chunkText } from './chunker.js';
|
|
11
|
+
import { buildAnnIndex } from './ann.js';
|
|
12
|
+
import { progress, progressDone, success, info, spinner } from './cli.js';
|
|
13
|
+
import chalk from 'chalk';
|
|
14
|
+
|
|
15
|
+
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB per file
|
|
16
|
+
const MAX_FILE_COUNT = 100000; // 100k files per reindex
|
|
17
|
+
|
|
18
|
+
function sanitizePath(filePath) {
|
|
19
|
+
return path.resolve(filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function indexBatch(db, skills, { fast = false } = {}) {
|
|
23
|
+
const upsertSkill = db.prepare(`
|
|
24
|
+
INSERT INTO skills (id, name, description, path, source, content, hash, version, author, license, updated_at, downloads, verified)
|
|
25
|
+
VALUES (@id, @name, @description, @path, @source, @content, @hash, @version, @author, @license, @updated_at, @downloads, @verified)
|
|
26
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
27
|
+
name = excluded.name,
|
|
28
|
+
description = excluded.description,
|
|
29
|
+
path = excluded.path,
|
|
30
|
+
content = excluded.content,
|
|
31
|
+
hash = excluded.hash,
|
|
32
|
+
version = excluded.version,
|
|
33
|
+
author = excluded.author,
|
|
34
|
+
license = excluded.license,
|
|
35
|
+
updated_at = excluded.updated_at,
|
|
36
|
+
downloads = excluded.downloads,
|
|
37
|
+
verified = excluded.verified
|
|
38
|
+
`);
|
|
39
|
+
const deleteChunks = db.prepare('DELETE FROM chunks WHERE skill_id = ?');
|
|
40
|
+
const deleteEdges = db.prepare('DELETE FROM edges WHERE from_skill = ?');
|
|
41
|
+
const upsertChunk = db.prepare('INSERT OR REPLACE INTO chunks (skill_id, chunk_index, text, embedding) VALUES (?, ?, ?, ?)');
|
|
42
|
+
const upsertEdge = db.prepare('INSERT OR IGNORE INTO edges (from_skill, to_skill) VALUES (?, ?)');
|
|
43
|
+
const upsertFts = db.prepare(`INSERT OR REPLACE INTO skills_fts(id, name, description, content) VALUES (?, ?, ?, ?)`);
|
|
44
|
+
|
|
45
|
+
const allChunks = [];
|
|
46
|
+
if (!fast) {
|
|
47
|
+
for (const skill of skills) {
|
|
48
|
+
const id = skillId(skill.source, skill.name);
|
|
49
|
+
const chunks = chunkText(skill.name + ' ' + skill.description + '\n' + skill.content);
|
|
50
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
51
|
+
allChunks.push({ id, skill, chunkIndex: i, text: chunks[i] });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let embeddings = [];
|
|
57
|
+
if (!fast && allChunks.length) {
|
|
58
|
+
const texts = allChunks.map(c => c.text);
|
|
59
|
+
process.stdout.write(` Embedding ${texts.length} chunks...`);
|
|
60
|
+
embeddings = await embedBatch(texts);
|
|
61
|
+
process.stdout.write('\r' + ' '.repeat(40) + '\r');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// pass 1: upsert all skills + chunks (no edges yet)
|
|
65
|
+
db.transaction(() => {
|
|
66
|
+
for (const skill of skills) {
|
|
67
|
+
const id = skillId(skill.source, skill.name);
|
|
68
|
+
upsertSkill.run({ id, name: skill.name, description: skill.description, path: skill.path, source: skill.source, content: skill.content, hash: skill.hash || null, version: skill.version || null, author: skill.author || null, license: skill.license || null, updated_at: skill.updated_at || null, downloads: skill.downloads ?? 0, verified: skill.verified ? 1 : 0 });
|
|
69
|
+
upsertFts.run(id, skill.name, skill.description || '', skill.content || '');
|
|
70
|
+
if (!fast) {
|
|
71
|
+
deleteChunks.run(id);
|
|
72
|
+
deleteEdges.run(id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!fast) {
|
|
76
|
+
for (let i = 0; i < allChunks.length; i++) {
|
|
77
|
+
const { id, chunkIndex, text } = allChunks[i];
|
|
78
|
+
upsertChunk.run(id, chunkIndex, text, vecToBlob(embeddings[i]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
|
|
83
|
+
// pass 2: resolve edges after all skills in batch are committed
|
|
84
|
+
const resolveSameSource = db.prepare("SELECT id FROM skills WHERE name = ? AND source = ? LIMIT 1");
|
|
85
|
+
const resolveAny = db.prepare("SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1");
|
|
86
|
+
db.transaction(() => {
|
|
87
|
+
for (const skill of skills) {
|
|
88
|
+
const id = skillId(skill.source, skill.name);
|
|
89
|
+
for (const calledName of skill.calls) {
|
|
90
|
+
// prefer a skill in the same source, fall back to any, then bare name
|
|
91
|
+
const same = resolveSameSource.get(calledName, skill.source);
|
|
92
|
+
const resolved = same || resolveAny.get(calledName);
|
|
93
|
+
upsertEdge.run(id, resolved ? resolved.id : calledName);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function indexAll({ fast = false } = {}) {
|
|
100
|
+
const config = loadConfig();
|
|
101
|
+
const db = getDb();
|
|
102
|
+
|
|
103
|
+
// collect all files on disk — use longest-matching source for files in subdirs
|
|
104
|
+
// (e.g. skills-store/marketplace/*.md → 'marketplace', not 'skills-store')
|
|
105
|
+
const normalizedSources = config.sources.map(s => ({
|
|
106
|
+
...s,
|
|
107
|
+
normDir: path.resolve(s.dir),
|
|
108
|
+
})).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
|
|
109
|
+
|
|
110
|
+
const seenFiles = new Set();
|
|
111
|
+
const allFiles = [];
|
|
112
|
+
for (const { dir, source } of normalizedSources) {
|
|
113
|
+
const files = globSync(`${dir}/**/*.md`);
|
|
114
|
+
for (const f of files) {
|
|
115
|
+
const norm = sanitizePath(f);
|
|
116
|
+
if (!seenFiles.has(norm)) {
|
|
117
|
+
seenFiles.add(norm);
|
|
118
|
+
allFiles.push({ file: norm, source });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (allFiles.length > MAX_FILE_COUNT) {
|
|
122
|
+
info(chalk.yellow(`Reached max file count (${MAX_FILE_COUNT}) — truncating`));
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const total = allFiles.length;
|
|
127
|
+
info(`Found ${chalk.white.bold(total)} files`);
|
|
128
|
+
|
|
129
|
+
// reconcile: remove skills whose files no longer exist OR whose name changed
|
|
130
|
+
const allDbSkills = db.prepare('SELECT id, path, name, source FROM skills').all();
|
|
131
|
+
const existingPaths = new Set(allFiles.map(f => f.file));
|
|
132
|
+
let removed = 0;
|
|
133
|
+
|
|
134
|
+
// build expected id map from disk
|
|
135
|
+
const expectedIds = new Map();
|
|
136
|
+
for (const { file, source } of allFiles) {
|
|
137
|
+
try {
|
|
138
|
+
const parsed = parseSkillFile(file, source);
|
|
139
|
+
expectedIds.set(file, skillId(source, parsed.name));
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const row of allDbSkills) {
|
|
144
|
+
const pathGone = !existingPaths.has(row.path);
|
|
145
|
+
const idChanged = expectedIds.has(row.path) && expectedIds.get(row.path) !== row.id;
|
|
146
|
+
if (pathGone || idChanged) {
|
|
147
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
|
|
148
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
|
|
149
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
|
|
150
|
+
db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(row.id);
|
|
151
|
+
removed++;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (removed > 0) info(`Removed ${chalk.yellow(removed)} stale/deleted skills`);
|
|
155
|
+
|
|
156
|
+
let count = 0;
|
|
157
|
+
let errors = 0;
|
|
158
|
+
let skipped = 0;
|
|
159
|
+
let classifierRemoved = 0;
|
|
160
|
+
let batch = [];
|
|
161
|
+
const start = Date.now();
|
|
162
|
+
|
|
163
|
+
// Build a path→{hash,id} map from DB for O(1) lookups
|
|
164
|
+
const dbByPath = new Map();
|
|
165
|
+
for (const row of db.prepare('SELECT id, path, hash FROM skills').all()) {
|
|
166
|
+
dbByPath.set(row.path, row);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const { file, source } of allFiles) {
|
|
170
|
+
try {
|
|
171
|
+
// 1. Read file once (with size gate)
|
|
172
|
+
let raw;
|
|
173
|
+
try {
|
|
174
|
+
const stat = fs.statSync(file);
|
|
175
|
+
if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
|
|
176
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
177
|
+
} catch { skipped++; count++; continue; }
|
|
178
|
+
|
|
179
|
+
// 2. Hash first — cheapest check
|
|
180
|
+
const hash = createHash('md5').update(raw).digest('hex');
|
|
181
|
+
|
|
182
|
+
// 3. If path already in DB with same hash → skip without parsing
|
|
183
|
+
const dbRow = dbByPath.get(file);
|
|
184
|
+
if (dbRow?.hash === hash) {
|
|
185
|
+
skipped++; count++;
|
|
186
|
+
if (count % 200 === 0) {
|
|
187
|
+
const eta = Math.round((total - count) * (Date.now() - start) / count / 1000);
|
|
188
|
+
progress(count, total, { skipped, eta, errors });
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 4. Only now check if it's a real skill (content already in memory)
|
|
194
|
+
if (!isSkillFile(file, raw)) { skipped++; count++; continue; }
|
|
195
|
+
|
|
196
|
+
const parsed = parseSkillFile(file, source, { raw });
|
|
197
|
+
batch.push({ ...parsed, hash });
|
|
198
|
+
|
|
199
|
+
if (batch.length >= BATCH_SIZE) {
|
|
200
|
+
const filtered = await filterWithClassifier(batch);
|
|
201
|
+
classifierRemoved += batch.length - filtered.length;
|
|
202
|
+
await indexBatch(db, filtered, { fast });
|
|
203
|
+
count += filtered.length;
|
|
204
|
+
batch = [];
|
|
205
|
+
const eta = count > 0 ? Math.round((total - count) * (Date.now() - start) / count / 1000) : '?';
|
|
206
|
+
progress(count, total, { skipped, eta, errors });
|
|
207
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
208
|
+
}
|
|
209
|
+
} catch (e) {
|
|
210
|
+
errors++;
|
|
211
|
+
console.error(`[PromptGraph] Error indexing ${file}: ${e.message}`);
|
|
212
|
+
try {
|
|
213
|
+
const stale = db.prepare('SELECT id FROM skills WHERE path = ?').get(file);
|
|
214
|
+
if (stale) {
|
|
215
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(stale.id);
|
|
216
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(stale.id);
|
|
217
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(stale.id, stale.id);
|
|
218
|
+
db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(stale.id);
|
|
219
|
+
}
|
|
220
|
+
} catch {}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (batch.length > 0) {
|
|
225
|
+
const filtered = await filterWithClassifier(batch);
|
|
226
|
+
classifierRemoved += batch.length - filtered.length;
|
|
227
|
+
await indexBatch(db, filtered, { fast });
|
|
228
|
+
count += filtered.length;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
progress(total, total, { skipped, errors });
|
|
232
|
+
progressDone();
|
|
233
|
+
if (!fast) {
|
|
234
|
+
const spin = spinner('Building ANN index...');
|
|
235
|
+
spin.start();
|
|
236
|
+
await buildAnnIndex();
|
|
237
|
+
spin.stop();
|
|
238
|
+
}
|
|
239
|
+
const stats = [`${errors} errors`, `${skipped} skipped`, `${removed} removed`];
|
|
240
|
+
if (classifierRemoved > 0) stats.push(`${classifierRemoved} filtered`);
|
|
241
|
+
success(`Indexed ${chalk.white.bold(count)} skills ${chalk.gray(`(${stats.join(', ')})`)}`);
|
|
242
|
+
if (fast) info(chalk.yellow('Fast mode: keyword search only. Run `pg reindex` for semantic search.'));
|
|
243
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
244
|
+
info(chalk.gray(`Time: ${elapsed}s`));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export async function indexFile(filePath, source) {
|
|
248
|
+
const safe = sanitizePath(filePath);
|
|
249
|
+
const stat = fs.statSync(safe);
|
|
250
|
+
if (stat.size > MAX_FILE_SIZE) throw new Error(`File too large: ${filePath}`);
|
|
251
|
+
const db = getDb();
|
|
252
|
+
const raw = fs.readFileSync(safe, 'utf8');
|
|
253
|
+
const hash = createHash('md5').update(raw).digest('hex');
|
|
254
|
+
const skill = parseSkillFile(safe, source, { raw });
|
|
255
|
+
await indexBatch(db, [{ ...skill, hash }]);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Index only one source directory — much faster than indexAll after a bundle install
|
|
259
|
+
export async function indexSource(dir, sourceName) {
|
|
260
|
+
const db = getDb();
|
|
261
|
+
const files = globSync(`${dir}/**/*.md`);
|
|
262
|
+
const total = files.length;
|
|
263
|
+
info(`Indexing ${chalk.white.bold(total)} files from ${sourceName}...`);
|
|
264
|
+
|
|
265
|
+
const dbByPath = new Map();
|
|
266
|
+
for (const row of db.prepare('SELECT id, path, hash FROM skills WHERE source = ?').all(sourceName)) {
|
|
267
|
+
dbByPath.set(row.path, row);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Remove skills from this source whose files are gone
|
|
271
|
+
const existingPaths = new Set(files.map(f => path.resolve(f)));
|
|
272
|
+
for (const [, row] of dbByPath) {
|
|
273
|
+
if (!existingPaths.has(path.resolve(row.path))) {
|
|
274
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
|
|
275
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
|
|
276
|
+
db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
let count = 0, skipped = 0, errors = 0, batch = [];
|
|
281
|
+
const start = Date.now();
|
|
282
|
+
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
try {
|
|
285
|
+
const norm = sanitizePath(file);
|
|
286
|
+
const stat = fs.statSync(norm);
|
|
287
|
+
if (stat.size > MAX_FILE_SIZE) { skipped++; count++; continue; }
|
|
288
|
+
const raw = fs.readFileSync(norm, 'utf8');
|
|
289
|
+
const hash = createHash('md5').update(raw).digest('hex');
|
|
290
|
+
const dbRow = dbByPath.get(file);
|
|
291
|
+
if (dbRow?.hash === hash) { skipped++; count++; continue; }
|
|
292
|
+
if (!isSkillFile(file, raw)) { skipped++; count++; continue; }
|
|
293
|
+
const parsed = parseSkillFile(file, sourceName, { raw });
|
|
294
|
+
batch.push({ ...parsed, hash });
|
|
295
|
+
if (batch.length >= BATCH_SIZE) {
|
|
296
|
+
await indexBatch(db, batch);
|
|
297
|
+
count += batch.length; batch = [];
|
|
298
|
+
progress(count, total, { skipped, errors });
|
|
299
|
+
await new Promise(r => setImmediate ? setImmediate(r) : setTimeout(r, 0));
|
|
300
|
+
}
|
|
301
|
+
} catch (e) { errors++; count++; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (batch.length > 0) { await indexBatch(db, batch); count += batch.length; }
|
|
305
|
+
progress(total, total, { skipped, errors });
|
|
306
|
+
progressDone();
|
|
307
|
+
await buildAnnIndex();
|
|
308
|
+
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
|
309
|
+
success(`Indexed ${chalk.white.bold(count)} skills from ${sourceName} ${chalk.gray(`(${skipped} skipped, ${elapsed}s)`)}`);
|
|
310
|
+
}
|