promptgraph-mcp 2.6.0 → 2.6.2
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 +31 -10
- package/chunker.js +1 -1
- package/commands/bundle.js +150 -0
- package/commands/doctor.js +15 -0
- package/commands/import.js +7 -0
- package/commands/init.js +37 -0
- package/commands/marketplace.js +114 -0
- package/commands/reindex.js +10 -0
- package/commands/search.js +55 -0
- package/commands/setup.js +19 -0
- package/commands/status.js +110 -0
- package/commands/train.js +18 -0
- package/commands/update.js +49 -0
- package/commands/validate.js +63 -0
- package/index.js +1 -1
- package/marketplace.js +44 -7
- package/package.json +3 -2
- package/search.js +20 -5
- package/src/reranker/reranker.js +76 -12
- package/src/store/index.js +1 -1
package/README.md
CHANGED
|
@@ -14,13 +14,17 @@ Instead of loading every `.md` skill into your context, Claude calls `pg_search`
|
|
|
14
14
|
```
|
|
15
15
|
pg_search("refactor without breaking tests")
|
|
16
16
|
→ embed query (BGE-Small-EN, 384-dim)
|
|
17
|
-
→ flat
|
|
18
|
-
→
|
|
19
|
-
→
|
|
17
|
+
→ ANN index (HNSW by default, flat fallback) — topK×4 candidates
|
|
18
|
+
→ BM25 (FTS5) — topK×4 candidates
|
|
19
|
+
→ hybrid merge (embedWeight × cosine + bm25Weight × BM25)
|
|
20
|
+
→ term-overlap reranker (TF frequency + header-position boost)
|
|
21
|
+
→ return topK skill paths + snippets
|
|
22
|
+
→ Claude reads only the files it needs
|
|
20
23
|
```
|
|
21
24
|
|
|
22
|
-
**Index:** SQLite + Float32 BLOB embeddings
|
|
23
|
-
|
|
25
|
+
**Index:** SQLite + Float32 BLOB embeddings + HNSW approximate nearest neighbor (configurable: `PG_VECTOR_STORE=flat` for brute-force cosine). No external vector DB, no API key, no cloud.
|
|
26
|
+
|
|
27
|
+
**Reranker:** lightweight term-overlap scorer (binary overlap + TF frequency + header-position boost). Not a cross-encoder — disable via `PG_RERANKER=0`.
|
|
24
28
|
|
|
25
29
|
**File watcher:** `chokidar` detects `.md` changes and reindexes automatically (MCP server mode only).
|
|
26
30
|
|
|
@@ -34,16 +38,27 @@ No external vector DB, no API key, no cloud.
|
|
|
34
38
|
| 88 skills reindexed (unchanged, hash match) | **< 1 s** |
|
|
35
39
|
| `pg reindex --fast` (3000 files, keyword only) | **~30 s** |
|
|
36
40
|
| `pg reindex` full embed (3000 files) | **~30 min** |
|
|
37
|
-
| Semantic search query | **< 50 ms** |
|
|
41
|
+
| Semantic search query (HNSW) | **< 50 ms** |
|
|
42
|
+
| Semantic search query (flat, brute-force) | **< 200 ms** |
|
|
38
43
|
| Model size (BGE-Small-EN-v1.5, one-time download) | **23 MB** |
|
|
39
44
|
| Embedding dimensions | **384** |
|
|
40
|
-
| Max chunks per skill | **
|
|
45
|
+
| Max chunks per skill | **8** (configurable: `PG_MAX_CHUNKS`) |
|
|
41
46
|
| Embedding batch size | **256** |
|
|
42
47
|
|
|
43
48
|
> ONNX model initialization (~2–3 min) happens once on first use and is cached in `~/.claude/.promptgraph/model-cache/`.
|
|
44
49
|
|
|
45
50
|
---
|
|
46
51
|
|
|
52
|
+
## Environment Variables
|
|
53
|
+
|
|
54
|
+
| Variable | Default | Description |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `PG_VECTOR_STORE` | `hnsw` | Vector index: `hnsw` (ANN, faster at scale) or `flat` (brute-force cosine) |
|
|
57
|
+
| `PG_RERANKER` | `1` | Enable term-overlap reranker after hybrid search (set `0` to disable) |
|
|
58
|
+
| `PG_MAX_CHUNKS` | `8` | Max semantic chunks per skill (higher = more detail per long file, more embedding cost) |
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
47
62
|
## Quick Start
|
|
48
63
|
|
|
49
64
|
```bash
|
|
@@ -154,9 +169,15 @@ Claude uses these automatically when the MCP server is running:
|
|
|
154
169
|
|
|
155
170
|
## Search modes
|
|
156
171
|
|
|
157
|
-
Search
|
|
158
|
-
|
|
159
|
-
|
|
172
|
+
Search runs a multi-stage pipeline:
|
|
173
|
+
|
|
174
|
+
1. **ANN retrieval** — HNSW approximate nearest neighbor (default) or flat brute-force cosine (`PG_VECTOR_STORE=flat`)
|
|
175
|
+
2. **BM25 keyword** — SQLite FTS5
|
|
176
|
+
3. **Hybrid fusion** — weighted sum (embedding × embedWeight + BM25 × bm25Weight, adaptive per query)
|
|
177
|
+
4. **Reranker** — term-overlap rescoring with TF frequency and header-position boost (disable via `PG_RERANKER=0`)
|
|
178
|
+
5. **Rating boost** — success/fail history adjusts final ranking
|
|
179
|
+
|
|
180
|
+
Falls back to FTS5 only if no embeddings exist.
|
|
160
181
|
|
|
161
182
|
---
|
|
162
183
|
|
package/chunker.js
CHANGED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import { spawnSync } from 'child_process';
|
|
7
|
+
|
|
8
|
+
export default async function handler(args, bin) {
|
|
9
|
+
if (args[1] === 'update') {
|
|
10
|
+
const { loadConfig: _lcUpd, SKILLS_STORE_DIR: _ssDir } = await import('../config.js');
|
|
11
|
+
const { indexSource } = await import('../indexer.js');
|
|
12
|
+
const cfg = _lcUpd();
|
|
13
|
+
const githubSources = cfg.sources.filter(s => s.source.startsWith('github:'));
|
|
14
|
+
|
|
15
|
+
if (!githubSources.length) { info('No GitHub bundles installed.'); process.exit(0); }
|
|
16
|
+
|
|
17
|
+
const targetId = args[2];
|
|
18
|
+
const toUpdate = targetId
|
|
19
|
+
? githubSources.filter(s => s.source.toLowerCase().includes(targetId.toLowerCase()))
|
|
20
|
+
: githubSources;
|
|
21
|
+
|
|
22
|
+
if (!toUpdate.length) { error(`No installed bundle matching "${targetId}"`); process.exit(1); }
|
|
23
|
+
|
|
24
|
+
let updated = 0, unchanged = 0, failed = 0;
|
|
25
|
+
|
|
26
|
+
for (const src of toUpdate) {
|
|
27
|
+
const repoName = src.source.replace('github:', '');
|
|
28
|
+
const dest = src.dir.replace(/[/\\]skills$|[/\\]commands$|[/\\]prompts$/, '');
|
|
29
|
+
const repoRoot = fs.existsSync(path.join(dest, '.git')) ? dest : src.dir;
|
|
30
|
+
|
|
31
|
+
if (!fs.existsSync(path.join(repoRoot, '.git'))) {
|
|
32
|
+
console.log(chalk.gray(` skip ${repoName} (not a git repo)`));
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.stdout.write(` Checking ${chalk.white(repoName)}... `);
|
|
37
|
+
|
|
38
|
+
const before = spawnSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30000 }).stdout.trim();
|
|
39
|
+
|
|
40
|
+
const fetch = spawnSync('git', ['-C', repoRoot, 'fetch', '--depth=1', 'origin'], { stdio: 'pipe', timeout: 60000 });
|
|
41
|
+
if (fetch.status !== 0) {
|
|
42
|
+
console.log(chalk.red('fetch failed'));
|
|
43
|
+
failed++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const reset = spawnSync('git', ['-C', repoRoot, 'reset', '--hard', 'origin/HEAD'], { stdio: 'pipe', timeout: 30000 });
|
|
47
|
+
if (reset.status !== 0) {
|
|
48
|
+
spawnSync('git', ['-C', repoRoot, 'reset', '--hard', 'origin/main'], { stdio: 'pipe', timeout: 30000 });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const after = spawnSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8', timeout: 30000 }).stdout.trim();
|
|
52
|
+
|
|
53
|
+
if (before === after) {
|
|
54
|
+
console.log(chalk.gray('already up to date'));
|
|
55
|
+
unchanged++;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const diff = spawnSync('git', ['-C', repoRoot, 'diff', '--name-only', before, after], { encoding: 'utf8', timeout: 15000 });
|
|
60
|
+
const changedMd = (diff.stdout || '').split('\n').filter(f => f.endsWith('.md')).length;
|
|
61
|
+
console.log(chalk.green(`${changedMd} files changed`) + chalk.gray(` (${before.slice(0,7)} → ${after.slice(0,7)})`));
|
|
62
|
+
|
|
63
|
+
await indexSource(src.dir, src.source);
|
|
64
|
+
updated++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log();
|
|
68
|
+
if (updated) success(`Updated ${updated} bundle(s)`);
|
|
69
|
+
if (unchanged) info(chalk.gray(`${unchanged} already up to date`));
|
|
70
|
+
if (failed) error(`${failed} failed`);
|
|
71
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (args[1] === 'install') {
|
|
75
|
+
const { installBundle } = await import('../marketplace.js');
|
|
76
|
+
const result = await installBundle(args[2]);
|
|
77
|
+
if (result?.error) { error(result.error); process.exit(1); }
|
|
78
|
+
success(result.type === 'repo_import' ? `Imported from ${result.repo_url}` : `Installed ${result.installed?.length || 0} skills`);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
if (args[1] === 'add-repo') {
|
|
82
|
+
const doPush = args[args.length - 1] === '--push';
|
|
83
|
+
const repoArg = doPush ? args[2] : args[2];
|
|
84
|
+
if (!repoArg || !repoArg.includes('/')) { error('Usage: pg bundle add-repo <owner/repo> [--push]'); process.exit(1); }
|
|
85
|
+
const repo = repoArg.replace('https://github.com/', '').replace('.git', '');
|
|
86
|
+
|
|
87
|
+
const { detectSkillsDirFromAPI: _detectDir } = await import('../github-import.js');
|
|
88
|
+
process.stdout.write(chalk.gray(` Checking ${repo} for skill subdirectory... `));
|
|
89
|
+
const detected = await _detectDir(repo);
|
|
90
|
+
if (!detected) {
|
|
91
|
+
console.log(chalk.red('none found'));
|
|
92
|
+
error(
|
|
93
|
+
`Cannot publish: no skill subdirectory found in ${repo}\n` +
|
|
94
|
+
` Expected: skills/, prompts/, commands/, agents/, or any folder with .md files\n` +
|
|
95
|
+
` Visit: https://github.com/${repo}`
|
|
96
|
+
);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
console.log(chalk.green(`found: ${detected.label}/`));
|
|
100
|
+
const name = repo.split('/')[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
101
|
+
const id = repo.replace('/', '-').toLowerCase();
|
|
102
|
+
const bundle = {
|
|
103
|
+
id, name, repo_url: repo, author: repo.split('/')[0],
|
|
104
|
+
description: `Skills from ${repo}`,
|
|
105
|
+
tags: ['community'],
|
|
106
|
+
stars: 0
|
|
107
|
+
};
|
|
108
|
+
const json = JSON.stringify(bundle, null, 2);
|
|
109
|
+
|
|
110
|
+
if (doPush) {
|
|
111
|
+
const registryDir = path.join(os.tmpdir(), 'pg-push-registry');
|
|
112
|
+
const gitEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
|
|
113
|
+
const git = (gArgs, opts = {}) => { const r = spawnSync('git', gArgs, { ...opts, env: gitEnv, stdio: 'pipe', timeout: 60000 }); if (r.status !== 0) { error(r.stderr?.toString() || r.stdout?.toString() || 'git error'); process.exit(1); } return r; };
|
|
114
|
+
if (fs.existsSync(registryDir)) {
|
|
115
|
+
git(['-C', registryDir, 'pull']);
|
|
116
|
+
} else {
|
|
117
|
+
git(['clone', '--depth=1', 'https://github.com/NeiP4n/promptgraph-registry.git', registryDir]);
|
|
118
|
+
}
|
|
119
|
+
const regFile = path.join(registryDir, 'registry.json');
|
|
120
|
+
const reg = JSON.parse(fs.readFileSync(regFile, 'utf8'));
|
|
121
|
+
if (reg.bundles.find(b => b.id === id)) { error(`Bundle "${id}" already exists`); process.exit(1); }
|
|
122
|
+
reg.bundles.push(bundle);
|
|
123
|
+
reg.updated = new Date().toISOString().slice(0, 10);
|
|
124
|
+
fs.writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
|
|
125
|
+
fs.writeFileSync(path.join(registryDir, 'bundles', `${id}.json`), json + '\n');
|
|
126
|
+
git(['-C', registryDir, 'config', 'user.email', 'pg-bot@promptgraph.ai']);
|
|
127
|
+
git(['-C', registryDir, 'config', 'user.name', 'PromptGraph Bot']);
|
|
128
|
+
git(['-C', registryDir, 'add', '-A']);
|
|
129
|
+
git(['-C', registryDir, 'commit', '-m', `bundle: ${name} (${repo})`]);
|
|
130
|
+
git(['-C', registryDir, 'push']);
|
|
131
|
+
success(`Bundle "${id}" pushed to registry`);
|
|
132
|
+
} else {
|
|
133
|
+
const tmp = path.join(os.tmpdir(), `pg-bundle-${id}.json`);
|
|
134
|
+
fs.writeFileSync(tmp, json);
|
|
135
|
+
const { publishBundle } = await import('../marketplace.js');
|
|
136
|
+
const result = await publishBundle(tmp);
|
|
137
|
+
fs.unlinkSync(tmp);
|
|
138
|
+
if (result?.error) { error(result.error); process.exit(1); }
|
|
139
|
+
if (result.gh_not_installed) {
|
|
140
|
+
console.log('\n' + result.instructions);
|
|
141
|
+
console.log(chalk.gray('\nBundle JSON:\n') + chalk.white(json));
|
|
142
|
+
} else {
|
|
143
|
+
success(`Bundle proposed! Submit: ${result.submit_url}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
148
|
+
error('Usage: pg bundle install <id> | pg bundle add-repo <owner/repo> [--push]');
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { runDoctor } = await import('../doctor.js');
|
|
6
|
+
const spin = (await import('../cli.js')).spinner('Checking database...');
|
|
7
|
+
spin.start();
|
|
8
|
+
const r = runDoctor();
|
|
9
|
+
spin.stop();
|
|
10
|
+
success('Database checked');
|
|
11
|
+
info(`Removed: ${r.orphanChunks} chunks, ${r.orphanRatings} ratings, ${r.orphanFromEdges + r.danglingEdges} edges`);
|
|
12
|
+
if (r.duplicatePaths > 0) info(chalk.yellow(`Warning: ${r.duplicatePaths} duplicate paths`));
|
|
13
|
+
info(chalk.gray(`Now: ${r.totalSkills} skills, ${r.totalChunks} chunks, ${r.totalEdges} edges`));
|
|
14
|
+
process.exit(0);
|
|
15
|
+
}
|
package/commands/init.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import boxen from 'boxen';
|
|
4
|
+
|
|
5
|
+
export default async function handler(args, bin) {
|
|
6
|
+
const { promptConfig } = await import('../config.js');
|
|
7
|
+
const { indexAll } = await import('../indexer.js');
|
|
8
|
+
const os = await import('os');
|
|
9
|
+
const fs = await import('fs');
|
|
10
|
+
const path = await import('path');
|
|
11
|
+
const commandsDir = path.default.join(os.default.homedir(), '.claude', 'commands');
|
|
12
|
+
if (!fs.default.existsSync(commandsDir)) {
|
|
13
|
+
console.log(chalk.yellow('⚠') + ' ' + chalk.gray('~/.claude/commands/ not found — is Claude Code installed?'));
|
|
14
|
+
console.log(chalk.gray(' Install from: https://claude.ai/download\n'));
|
|
15
|
+
}
|
|
16
|
+
if (!args.includes('--yes') && !args.includes('-y')) {
|
|
17
|
+
const readline = await import('readline');
|
|
18
|
+
const rl = readline.default.createInterface({ input: process.stdin, output: process.stdout });
|
|
19
|
+
const answer = await new Promise(r => rl.question(
|
|
20
|
+
chalk.yellow(' ⚠') + chalk.gray(' First run downloads ~23 MB embedding model (BGE-Small-EN).\n Proceed? [Y/n] '), r
|
|
21
|
+
));
|
|
22
|
+
rl.close();
|
|
23
|
+
if (answer.trim().toLowerCase() === 'n') { info('Aborted.'); process.exit(0); }
|
|
24
|
+
}
|
|
25
|
+
console.log(chalk.gray('\n Downloading embedding model (~23 MB, one-time)...\n'));
|
|
26
|
+
const config = await promptConfig();
|
|
27
|
+
await indexAll();
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(
|
|
30
|
+
boxen(
|
|
31
|
+
chalk.white.bold('Add to Claude Code settings.json:') + '\n\n' +
|
|
32
|
+
chalk.gray(JSON.stringify({ mcpServers: { promptgraph: { command: 'npx', args: ['promptgraph-mcp'] } } }, null, 2)),
|
|
33
|
+
{ padding: 1, borderStyle: 'round', borderColor: '#7C3AED', dimBorder: true }
|
|
34
|
+
)
|
|
35
|
+
);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
|
|
7
|
+
export default async function handler(args, bin) {
|
|
8
|
+
if (!process.stdout.isTTY) {
|
|
9
|
+
error('marketplace TUI requires an interactive terminal');
|
|
10
|
+
process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
const { browseMarketplace, browseBundles, installSkill, installBundle } = await import('../marketplace.js');
|
|
13
|
+
const { loadConfig: _lcMkt } = await import('../config.js');
|
|
14
|
+
const { getDb: _getDbMkt } = await import('../db.js');
|
|
15
|
+
const { spinner: spin2 } = await import('../cli.js');
|
|
16
|
+
const sp = spin2('Fetching marketplace...');
|
|
17
|
+
sp.start();
|
|
18
|
+
const [skills, bundles] = await Promise.all([browseMarketplace(1000), browseBundles(1000)]);
|
|
19
|
+
sp.stop();
|
|
20
|
+
|
|
21
|
+
if (skills?.error) { error(skills.error); process.exit(1); }
|
|
22
|
+
|
|
23
|
+
const { getCachedCount, refreshCountsInBackground } = await import('../bundle-counts.js');
|
|
24
|
+
const bundlesWithCounts = (Array.isArray(bundles) ? bundles : []).map(b => {
|
|
25
|
+
if (!b.repo_url) return b;
|
|
26
|
+
const cached = getCachedCount(b.repo_url);
|
|
27
|
+
return cached !== null ? { ...b, skillCount: cached } : b;
|
|
28
|
+
});
|
|
29
|
+
refreshCountsInBackground(bundlesWithCounts);
|
|
30
|
+
|
|
31
|
+
const installedSet = new Set();
|
|
32
|
+
try {
|
|
33
|
+
const cfg = _lcMkt();
|
|
34
|
+
const db = _getDbMkt();
|
|
35
|
+
const { SKILLS_STORE_DIR } = await import('../config.js');
|
|
36
|
+
const githubDir = path.join(SKILLS_STORE_DIR, 'github');
|
|
37
|
+
|
|
38
|
+
for (const b of (Array.isArray(bundles) ? bundles : [])) {
|
|
39
|
+
if (b.repo_url) {
|
|
40
|
+
const owner = b.repo_url.split('/')[0];
|
|
41
|
+
const repo = b.repo_url.split('/')[1];
|
|
42
|
+
const clonedName = `${owner}-${repo}`;
|
|
43
|
+
const clonedDir = path.join(githubDir, clonedName);
|
|
44
|
+
const dirExists = fs.existsSync(clonedDir) &&
|
|
45
|
+
fs.readdirSync(clonedDir).length > 0;
|
|
46
|
+
if (dirExists) installedSet.add(b.id);
|
|
47
|
+
} else if (Array.isArray(b.skills)) {
|
|
48
|
+
const allOnDisk = b.skills.every(sid => {
|
|
49
|
+
const row = db.prepare('SELECT path FROM skills WHERE id = ?').get(sid);
|
|
50
|
+
return row && fs.existsSync(row.path);
|
|
51
|
+
});
|
|
52
|
+
if (b.skills.length > 0 && allOnDisk) installedSet.add(b.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const row of db.prepare('SELECT id, path FROM skills WHERE source = ?').all('marketplace')) {
|
|
57
|
+
if (fs.existsSync(row.path)) installedSet.add(row.id);
|
|
58
|
+
}
|
|
59
|
+
} catch {}
|
|
60
|
+
|
|
61
|
+
const { runTUI } = await import('../tui.js');
|
|
62
|
+
const { loadConfig: _lcR, saveConfig: _scR, SKILLS_STORE_DIR: _ssR } = await import('../config.js');
|
|
63
|
+
const { getDb: _getDbR } = await import('../db.js');
|
|
64
|
+
|
|
65
|
+
await runTUI(
|
|
66
|
+
Array.isArray(skills) ? skills : [],
|
|
67
|
+
bundlesWithCounts,
|
|
68
|
+
async (item) => {
|
|
69
|
+
if (item.type === 'bundle') {
|
|
70
|
+
const r = await installBundle(item.id);
|
|
71
|
+
if (r?.error) throw new Error(r.error);
|
|
72
|
+
installedSet.add(item.id);
|
|
73
|
+
} else {
|
|
74
|
+
const r = await installSkill(item.code || item.id);
|
|
75
|
+
if (r?.error) throw new Error(r.error);
|
|
76
|
+
installedSet.add(item.id);
|
|
77
|
+
if (item.code) installedSet.add(item.code);
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
installedSet,
|
|
81
|
+
async (item) => {
|
|
82
|
+
const cfg = _lcR();
|
|
83
|
+
const db = _getDbR();
|
|
84
|
+
if (item.type === 'bundle' && item.repo_url) {
|
|
85
|
+
const owner = item.repo_url.split('/')[0];
|
|
86
|
+
const repo = item.repo_url.split('/')[1];
|
|
87
|
+
const clonedName = `${owner}-${repo}`;
|
|
88
|
+
const clonedDir = path.join(_ssR, 'github', clonedName);
|
|
89
|
+
if (fs.existsSync(clonedDir)) fs.rmSync(clonedDir, { recursive: true, force: true });
|
|
90
|
+
const src = `github:${clonedName}`;
|
|
91
|
+
cfg.sources = cfg.sources.filter(s => s.source !== src && !s.dir.startsWith(clonedDir));
|
|
92
|
+
_scR(cfg);
|
|
93
|
+
db.prepare('DELETE FROM skills WHERE source = ?').run(src);
|
|
94
|
+
db.prepare('DELETE FROM chunks WHERE skill_id NOT IN (SELECT id FROM skills)').run();
|
|
95
|
+
} else if (item.type === 'bundle') {
|
|
96
|
+
const mktDir = path.join(_ssR, 'marketplace');
|
|
97
|
+
for (const sid of (item.skills || [])) {
|
|
98
|
+
const row = db.prepare('SELECT path FROM skills WHERE id = ?').get(sid);
|
|
99
|
+
if (row?.path && fs.existsSync(row.path)) fs.unlinkSync(row.path);
|
|
100
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(sid);
|
|
101
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(sid);
|
|
102
|
+
}
|
|
103
|
+
} else {
|
|
104
|
+
const row = db.prepare('SELECT path FROM skills WHERE id = ?').get(item.id);
|
|
105
|
+
if (row?.path && fs.existsSync(row.path)) fs.unlinkSync(row.path);
|
|
106
|
+
db.prepare('DELETE FROM skills WHERE id = ?').run(item.id);
|
|
107
|
+
db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(item.id);
|
|
108
|
+
}
|
|
109
|
+
installedSet.delete(item.id);
|
|
110
|
+
if (item.code) installedSet.delete(item.code);
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { indexAll } = await import('../indexer.js');
|
|
6
|
+
const fast = args.includes('--fast');
|
|
7
|
+
if (fast) info(chalk.yellow('Fast mode — skipping embeddings (keyword search only)'));
|
|
8
|
+
await indexAll({ fast });
|
|
9
|
+
process.exit(0);
|
|
10
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { VALID_TRUST_LEVELS, TRUST_LEVEL_BOOST } from '../marketplace.js';
|
|
4
|
+
|
|
5
|
+
export default async function handler(args, bin) {
|
|
6
|
+
const trustFilter = args.find(a => a.startsWith('--trust='));
|
|
7
|
+
const filteredArgs = args.filter(a => !a.startsWith('--trust='));
|
|
8
|
+
const query = filteredArgs.slice(1).join(' ');
|
|
9
|
+
if (!query) { error('Usage: ' + bin + ' search <query> [--trust=verified]'); process.exit(1); }
|
|
10
|
+
|
|
11
|
+
if (trustFilter) {
|
|
12
|
+
const level = trustFilter.split('=')[1];
|
|
13
|
+
if (!VALID_TRUST_LEVELS.includes(level)) {
|
|
14
|
+
error('Invalid trust level. Valid: ' + VALID_TRUST_LEVELS.join(', '));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { search: searchSkills } = await import('../search.js');
|
|
20
|
+
const { getDb } = await import('../db.js');
|
|
21
|
+
const { getByTrustLevel } = await import('../marketplace.js');
|
|
22
|
+
const spin = (await import('../cli.js')).spinner('Searching...');
|
|
23
|
+
spin.start();
|
|
24
|
+
let results = await searchSkills(query, 10);
|
|
25
|
+
spin.stop();
|
|
26
|
+
|
|
27
|
+
// Apply trust filter after search
|
|
28
|
+
if (trustFilter) {
|
|
29
|
+
const level = trustFilter.split('=')[1];
|
|
30
|
+
const entries = await getByTrustLevel(level);
|
|
31
|
+
const allowed = new Set(entries.map(e => e.id));
|
|
32
|
+
results = results.filter(r => allowed.has(r.id));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Enrich results with trust level
|
|
36
|
+
const db = getDb();
|
|
37
|
+
const enriched = results.map(s => {
|
|
38
|
+
const re = db.prepare('SELECT trust_level FROM registry_entries WHERE id = ?').get(s.id);
|
|
39
|
+
return { ...s, trustLevel: re ? re.trust_level : 'unknown' };
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!enriched.length) { info('No results for: ' + query); process.exit(0); }
|
|
43
|
+
const purple = chalk.hex('#7C3AED');
|
|
44
|
+
console.log();
|
|
45
|
+
enriched.forEach((s, i) => {
|
|
46
|
+
const score = chalk.dim((s.score * 100).toFixed(0) + '%');
|
|
47
|
+
const trustBadge = s.trustLevel !== 'unknown' && s.trustLevel !== 'community'
|
|
48
|
+
? ' ' + purple(s.trustLevel) : '';
|
|
49
|
+
console.log(' ' + chalk.dim(String(i + 1) + '.') + ' ' + chalk.bold.white(s.name) + trustBadge + ' ' + score);
|
|
50
|
+
console.log(' ' + chalk.dim(s.description || ''));
|
|
51
|
+
console.log(' ' + purple(s.source) + ' ' + chalk.dim(s.path));
|
|
52
|
+
console.log();
|
|
53
|
+
});
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { detectPlatforms, PLATFORMS } = await import('../platform.js');
|
|
6
|
+
const platformId = args[1];
|
|
7
|
+
if (!platformId) {
|
|
8
|
+
section('Detected platforms');
|
|
9
|
+
detectPlatforms().forEach(p => info(`${chalk.white(p.id.padEnd(16))} ${chalk.gray(p.name)}`));
|
|
10
|
+
console.log(chalk.gray('\n Usage: promptgraph-mcp setup <platform-id>\n'));
|
|
11
|
+
} else {
|
|
12
|
+
const platform = PLATFORMS[platformId];
|
|
13
|
+
if (!platform) { error(`Unknown platform: ${platformId}`); process.exit(1); }
|
|
14
|
+
platform.addMcp(platform);
|
|
15
|
+
success(`Registered in ${chalk.white(platform.name)}`);
|
|
16
|
+
info(chalk.gray(platform.configPath));
|
|
17
|
+
}
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import boxen from 'boxen';
|
|
7
|
+
|
|
8
|
+
export default async function handler(args, bin) {
|
|
9
|
+
const { loadConfig: _lc } = await import('../config.js');
|
|
10
|
+
const { getDb } = await import('../db.js');
|
|
11
|
+
const { fetchText } = await import('../marketplace.js');
|
|
12
|
+
const purple = chalk.hex('#7C3AED');
|
|
13
|
+
const cfg = _lc();
|
|
14
|
+
const db = getDb();
|
|
15
|
+
|
|
16
|
+
const sourceCounts = new Map();
|
|
17
|
+
for (const row of db.prepare('SELECT source, COUNT(*) as n FROM skills GROUP BY source').all()) {
|
|
18
|
+
sourceCounts.set(row.source, row.n);
|
|
19
|
+
}
|
|
20
|
+
const totalSkills = db.prepare('SELECT COUNT(*) as n FROM skills').get().n;
|
|
21
|
+
const totalBundles = cfg.sources.filter(s => s.source.startsWith('github:')).length;
|
|
22
|
+
|
|
23
|
+
let marketSkills = 0, marketBundles = 0;
|
|
24
|
+
try {
|
|
25
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
|
|
26
|
+
const reg = JSON.parse(await fetchText(REGISTRY_URL));
|
|
27
|
+
marketSkills = reg.skills?.length || 0;
|
|
28
|
+
marketBundles = reg.bundles?.length || 0;
|
|
29
|
+
} catch {}
|
|
30
|
+
|
|
31
|
+
console.log();
|
|
32
|
+
console.log(' ' + purple.bold('◆ PromptGraph Status'));
|
|
33
|
+
console.log(' ' + chalk.gray('─'.repeat(56)));
|
|
34
|
+
console.log();
|
|
35
|
+
|
|
36
|
+
const skillsLine = chalk.bold.white(`${totalSkills} skills`) +
|
|
37
|
+
(marketSkills ? chalk.gray(` / ${marketSkills} in registry`) : '');
|
|
38
|
+
const bundlesLine = chalk.bold.white(`${totalBundles} repos`) +
|
|
39
|
+
(marketBundles ? chalk.gray(` / ${marketBundles} in marketplace`) : '');
|
|
40
|
+
console.log(' ' + skillsLine + chalk.gray(' · ') + bundlesLine);
|
|
41
|
+
console.log();
|
|
42
|
+
|
|
43
|
+
const githubSources = cfg.sources.filter(s => s.source.startsWith('github:'));
|
|
44
|
+
const localSources = cfg.sources.filter(s => !s.source.startsWith('github:'));
|
|
45
|
+
|
|
46
|
+
if (localSources.length) {
|
|
47
|
+
console.log(' ' + purple('📁 Local'));
|
|
48
|
+
for (const s of localSources) {
|
|
49
|
+
const n = sourceCounts.get(s.source) || 0;
|
|
50
|
+
const exists = fs.existsSync(s.dir);
|
|
51
|
+
const label = exists ? chalk.white(s.source) : chalk.gray(s.source + ' (missing)');
|
|
52
|
+
console.log(' ' + label + chalk.gray(` ${n} skills · ${s.dir}`));
|
|
53
|
+
}
|
|
54
|
+
console.log();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (githubSources.length) {
|
|
58
|
+
console.log(' ' + purple('🌐 GitHub repos'));
|
|
59
|
+
for (const s of githubSources) {
|
|
60
|
+
const n = sourceCounts.get(s.source) || 0;
|
|
61
|
+
const repoName = s.source.replace('github:', '');
|
|
62
|
+
const exists = fs.existsSync(s.dir);
|
|
63
|
+
const label = exists ? chalk.white(repoName) : chalk.gray(repoName + ' (not cloned)');
|
|
64
|
+
console.log(' ' + label + chalk.gray(` ${n} skills`));
|
|
65
|
+
console.log(' ' + chalk.dim(s.dir));
|
|
66
|
+
}
|
|
67
|
+
console.log();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const marketplaceDir = path.join(os.homedir(), '.claude', 'skills-store', 'marketplace');
|
|
71
|
+
const installedBundles = fs.existsSync(marketplaceDir)
|
|
72
|
+
? fs.readdirSync(marketplaceDir, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name)
|
|
73
|
+
: [];
|
|
74
|
+
|
|
75
|
+
if (installedBundles.length) {
|
|
76
|
+
let registryBundles = [];
|
|
77
|
+
try {
|
|
78
|
+
const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
|
|
79
|
+
const text = await fetchText(REGISTRY_URL);
|
|
80
|
+
registryBundles = JSON.parse(text).bundles || [];
|
|
81
|
+
} catch {}
|
|
82
|
+
|
|
83
|
+
console.log(' ' + purple('📦 Installed marketplace bundles'));
|
|
84
|
+
for (const b of installedBundles) {
|
|
85
|
+
const bundle = registryBundles.find(rb => rb.id === b);
|
|
86
|
+
const name = bundle ? chalk.white.bold(bundle.name || b) : chalk.white(b);
|
|
87
|
+
const cat = bundle?.category ? chalk.dim(` [${bundle.category}]`) : '';
|
|
88
|
+
const n = sourceCounts.get('marketplace') || 0;
|
|
89
|
+
console.log(` ${name}${cat} ${chalk.gray(n + ' skills')}`);
|
|
90
|
+
}
|
|
91
|
+
console.log();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const emptyRepos = githubSources.filter(s => (sourceCounts.get(s.source) || 0) === 0 && fs.existsSync(s.dir));
|
|
95
|
+
if (emptyRepos.length) {
|
|
96
|
+
console.log(' ' + chalk.yellow(`⚠ ${emptyRepos.length} repo(s) not indexed`) + chalk.gray(' → run: ') + chalk.cyan(`${bin} reindex`));
|
|
97
|
+
console.log();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
console.log(
|
|
101
|
+
boxen(
|
|
102
|
+
chalk.dim('full reindex ') + chalk.cyan(`${bin} reindex`) + '\n' +
|
|
103
|
+
chalk.dim('install bundle ') + chalk.cyan(`${bin} bundle install <id>`) + '\n' +
|
|
104
|
+
chalk.dim('browse market ') + chalk.cyan(`${bin} marketplace bundles`),
|
|
105
|
+
{ padding: { top: 0, bottom: 0, left: 1, right: 1 }, borderStyle: 'round', borderColor: '#4B5563', dimBorder: true }
|
|
106
|
+
)
|
|
107
|
+
);
|
|
108
|
+
console.log();
|
|
109
|
+
process.exit(0);
|
|
110
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { train: trainModel } = await import('../src/filter/train.js');
|
|
6
|
+
const spin = (await import('../cli.js')).spinner('Training classifier...');
|
|
7
|
+
spin.start();
|
|
8
|
+
try {
|
|
9
|
+
const model = await trainModel();
|
|
10
|
+
spin.stop();
|
|
11
|
+
success(`Classifier trained (${model.counts.good} good, ${model.counts.bad} bad examples)`);
|
|
12
|
+
} catch (e) {
|
|
13
|
+
spin.stop();
|
|
14
|
+
error(`Training failed: ${e.message}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
process.exit(0);
|
|
18
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
|
|
4
|
+
export default async function handler(args, bin) {
|
|
5
|
+
const { spawnSync } = await import('child_process');
|
|
6
|
+
const { createRequire } = await import('module');
|
|
7
|
+
const https = (await import('https')).default;
|
|
8
|
+
const req = createRequire(import.meta.url);
|
|
9
|
+
const currentVersion = req('../package.json').version;
|
|
10
|
+
|
|
11
|
+
const spin = (await import('../cli.js')).spinner('Checking latest version...');
|
|
12
|
+
spin.start();
|
|
13
|
+
let latest = null;
|
|
14
|
+
try {
|
|
15
|
+
latest = await new Promise((res, rej) => {
|
|
16
|
+
const r = https.get('https://registry.npmjs.org/promptgraph-mcp/latest',
|
|
17
|
+
{ headers: { Accept: 'application/json' }, timeout: 8000 },
|
|
18
|
+
(resp) => {
|
|
19
|
+
let d = ''; resp.setEncoding('utf8');
|
|
20
|
+
resp.on('data', c => d += c);
|
|
21
|
+
resp.on('end', () => { try { res(JSON.parse(d).version); } catch { rej(new Error('bad response')); } });
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
r.on('error', rej);
|
|
25
|
+
r.on('timeout', () => { r.destroy(new Error('timeout')); });
|
|
26
|
+
});
|
|
27
|
+
} catch {}
|
|
28
|
+
spin.stop();
|
|
29
|
+
|
|
30
|
+
if (!latest) { error('Could not reach npm registry. Check your network.'); process.exit(1); }
|
|
31
|
+
if (latest === currentVersion) {
|
|
32
|
+
success(`Already on latest version ${chalk.white.bold('v' + currentVersion)}`);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
info(`Current: ${chalk.gray('v' + currentVersion)} → Latest: ${chalk.white.bold('v' + latest)}`);
|
|
37
|
+
const updateSpin = (await import('../cli.js')).spinner(`Installing promptgraph-mcp@latest (v${latest})...`);
|
|
38
|
+
updateSpin.start();
|
|
39
|
+
const result = spawnSync('npm', ['install', '-g', 'promptgraph-mcp@latest'], { encoding: 'utf8', stdio: 'pipe', shell: true });
|
|
40
|
+
updateSpin.stop();
|
|
41
|
+
|
|
42
|
+
if (result.status !== 0) {
|
|
43
|
+
error('Update failed:');
|
|
44
|
+
console.log(chalk.gray(result.stderr || result.stdout));
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
success(`Updated to ${chalk.white.bold('v' + latest)}`);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { colors, banner, success, error, info, section, table } from '../cli.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
|
|
5
|
+
export default async function handler(args, bin) {
|
|
6
|
+
const { validateSkill } = await import('../validator.js');
|
|
7
|
+
const file = args[1];
|
|
8
|
+
if (!file) { error('Usage: ' + bin + ' validate <skill.md>'); process.exit(1); }
|
|
9
|
+
|
|
10
|
+
const raw = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
11
|
+
|
|
12
|
+
if (raw) {
|
|
13
|
+
const { filterWithClassifier, isSkillFile: _isSkill } = await import('../parser.js');
|
|
14
|
+
const { hardFilter } = await import('../src/filter/hard-filter.js');
|
|
15
|
+
const { loadModel } = await import('../src/filter/train.js');
|
|
16
|
+
const { embed } = await import('../embedder.js');
|
|
17
|
+
const { classify } = await import('../src/filter/classifier.js');
|
|
18
|
+
|
|
19
|
+
const hfResult = hardFilter(file, raw);
|
|
20
|
+
const willIndex = _isSkill(file, raw);
|
|
21
|
+
const scoreLabel = willIndex ? chalk.green('✓ will be indexed') : chalk.red('✗ will be skipped by indexer');
|
|
22
|
+
console.log(chalk.bold('\n Indexing check: ') + scoreLabel);
|
|
23
|
+
|
|
24
|
+
const signals = [];
|
|
25
|
+
if (!hfResult.pass) {
|
|
26
|
+
signals.push(chalk.red(`✗ hard filter: ${hfResult.reason}`));
|
|
27
|
+
} else {
|
|
28
|
+
signals.push(chalk.green('✓ hard filter passed'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const centroids = loadModel();
|
|
32
|
+
if (centroids) {
|
|
33
|
+
try {
|
|
34
|
+
const vec = await embed(raw);
|
|
35
|
+
const decision = classify(vec, centroids, raw, file);
|
|
36
|
+
const pct = (decision.score * 100).toFixed(0);
|
|
37
|
+
if (decision.label === 'skill') signals.push(chalk.green(`✓ classifier: skill (${pct}%)`));
|
|
38
|
+
else if (decision.label === 'unsure') signals.push(chalk.yellow(`? classifier: unsure (${pct}%)`));
|
|
39
|
+
else signals.push(chalk.red(`✗ classifier: reject (${pct}%)`));
|
|
40
|
+
} catch {
|
|
41
|
+
signals.push(chalk.gray(' classifier: embed failed (skip)'));
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
signals.push(chalk.gray(' classifier: no model (run `pg train`)'));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (signals.length) {
|
|
48
|
+
signals.forEach(s => console.log(' ' + s));
|
|
49
|
+
}
|
|
50
|
+
console.log();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = validateSkill(file);
|
|
54
|
+
result.warnings.forEach(w => console.log(chalk.yellow('⚠') + ' ' + chalk.gray(w)));
|
|
55
|
+
if (result.ok) {
|
|
56
|
+
success('Skill is valid — ready to publish');
|
|
57
|
+
process.exit(0);
|
|
58
|
+
} else {
|
|
59
|
+
error('Validation failed:');
|
|
60
|
+
result.errors.forEach(e => console.log(' ' + chalk.red('•') + ' ' + e));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/index.js
CHANGED
package/marketplace.js
CHANGED
|
@@ -561,9 +561,38 @@ export function pruneInvalidRepos() {
|
|
|
561
561
|
|
|
562
562
|
// ── Trust level system ─────────────────────────────────────────────────────────
|
|
563
563
|
const VALID_TRUST_LEVELS = ['verified', 'official', 'community', 'trusted', 'unknown']
|
|
564
|
+
const TRUST_LEVEL_BOOST = { verified: 1.15, official: 1.10, trusted: 1.05, community: 1.0, unknown: 0.95 }
|
|
565
|
+
|
|
566
|
+
// Popularity = log(downloads+1) × (rating+1), then decayed by age (days since last_update, halved every 180 days)
|
|
567
|
+
function calcPopularity(downloads = 0, rating = 0, lastUpdateStr = null) {
|
|
568
|
+
const logDownloads = Math.log10((downloads || 0) + 1)
|
|
569
|
+
const ratingFactor = ((rating || 0) + 1) / 6 // normalised to 0–1
|
|
570
|
+
let pop = logDownloads * ratingFactor * 100
|
|
571
|
+
|
|
572
|
+
if (lastUpdateStr) {
|
|
573
|
+
const daysSince = (Date.now() - new Date(lastUpdateStr + 'Z').getTime()) / 86400000
|
|
574
|
+
if (daysSince > 0) {
|
|
575
|
+
pop *= Math.pow(0.5, daysSince / 180) // half-life 180 days
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return Math.round(pop * 100) / 100
|
|
579
|
+
}
|
|
564
580
|
|
|
565
|
-
|
|
566
|
-
|
|
581
|
+
// Auto-promote threshold: downloads at which a skill graduates to the next trust level
|
|
582
|
+
const AUTO_PROMOTE_THRESHOLDS = [
|
|
583
|
+
{ minDownloads: 10000, level: 'verified' },
|
|
584
|
+
{ minDownloads: 1000, level: 'official' },
|
|
585
|
+
{ minDownloads: 100, level: 'trusted' },
|
|
586
|
+
]
|
|
587
|
+
|
|
588
|
+
function autoPromote(downloads, currentLevel) {
|
|
589
|
+
const rank = VALID_TRUST_LEVELS.indexOf(currentLevel || 'unknown')
|
|
590
|
+
for (const t of AUTO_PROMOTE_THRESHOLDS) {
|
|
591
|
+
if (downloads >= t.minDownloads && VALID_TRUST_LEVELS.indexOf(t.level) > rank) {
|
|
592
|
+
return t.level
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return null
|
|
567
596
|
}
|
|
568
597
|
|
|
569
598
|
export async function setTrustLevel(name, level) {
|
|
@@ -586,28 +615,36 @@ export async function getByTrustLevel(level) {
|
|
|
586
615
|
return { error: `Invalid trust level "${level}". Must be one of: ${VALID_TRUST_LEVELS.join(', ')}` }
|
|
587
616
|
}
|
|
588
617
|
if (level) {
|
|
589
|
-
return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY
|
|
618
|
+
return db.prepare('SELECT * FROM registry_entries WHERE trust_level = ? ORDER BY downloads DESC, popularity DESC').all(level)
|
|
590
619
|
}
|
|
591
|
-
return db.prepare('SELECT * FROM registry_entries ORDER BY
|
|
620
|
+
return db.prepare('SELECT * FROM registry_entries ORDER BY downloads DESC, popularity DESC').all()
|
|
592
621
|
}
|
|
593
622
|
|
|
594
623
|
export async function incrementDownloads(name) {
|
|
595
624
|
const db = getDb()
|
|
596
625
|
const id = String(name)
|
|
597
|
-
const existing = db.prepare('SELECT downloads, rating FROM registry_entries WHERE id = ?').get(id)
|
|
626
|
+
const existing = db.prepare('SELECT downloads, rating, last_update, trust_level FROM registry_entries WHERE id = ?').get(id)
|
|
598
627
|
if (existing) {
|
|
599
628
|
const newDownloads = (existing.downloads || 0) + 1
|
|
600
|
-
const pop = calcPopularity(newDownloads, existing.rating || 0)
|
|
629
|
+
const pop = calcPopularity(newDownloads, existing.rating || 0, existing.last_update)
|
|
601
630
|
db.prepare('UPDATE registry_entries SET downloads = ?, popularity = ?, last_update = datetime(\'now\') WHERE id = ?')
|
|
602
631
|
.run(newDownloads, pop, id)
|
|
632
|
+
|
|
633
|
+
// Auto-promote if threshold crossed
|
|
634
|
+
const newLevel = autoPromote(newDownloads, existing.trust_level)
|
|
635
|
+
if (newLevel) {
|
|
636
|
+
db.prepare('UPDATE registry_entries SET trust_level = ? WHERE id = ?').run(newLevel, id)
|
|
637
|
+
}
|
|
603
638
|
} else {
|
|
604
639
|
const pop = calcPopularity(1, 0)
|
|
605
|
-
db.prepare('INSERT INTO registry_entries (id, downloads, popularity, last_update) VALUES (?, 1, ?, datetime(\'now\'))')
|
|
640
|
+
db.prepare('INSERT INTO registry_entries (id, downloads, popularity, trust_level, last_update) VALUES (?, 1, ?, \'unknown\', datetime(\'now\'))')
|
|
606
641
|
.run(id, pop)
|
|
607
642
|
}
|
|
608
643
|
return { ok: true }
|
|
609
644
|
}
|
|
610
645
|
|
|
646
|
+
export { VALID_TRUST_LEVELS, TRUST_LEVEL_BOOST, calcPopularity, autoPromote }
|
|
647
|
+
|
|
611
648
|
export async function rateSkill(name, rating) {
|
|
612
649
|
const db = getDb()
|
|
613
650
|
if (typeof rating !== 'number' || rating < 0 || rating > 5) {
|
package/package.json
CHANGED
package/search.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { embed, cosineSimilarity } from './embedder.js';
|
|
2
2
|
import { getDb, blobToVec } from './db.js';
|
|
3
3
|
import { annSearch } from './ann.js';
|
|
4
|
+
import { TRUST_LEVEL_BOOST } from './marketplace.js';
|
|
4
5
|
|
|
5
6
|
function getHybridWeights(query) {
|
|
6
7
|
const hasTechTerms = /[A-Z]|\d/.test(query);
|
|
@@ -10,11 +11,25 @@ function getHybridWeights(query) {
|
|
|
10
11
|
|
|
11
12
|
function applyRatingBoost(db, id, score) {
|
|
12
13
|
const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
|
|
14
|
+
let boost = 1.0;
|
|
13
15
|
if (r && (r.success + r.fail) > 3) {
|
|
14
|
-
|
|
15
|
-
return score * (0.85 + 0.15 * rating);
|
|
16
|
+
boost *= (0.85 + 0.15 * (r.success / (r.success + r.fail)));
|
|
16
17
|
}
|
|
17
|
-
|
|
18
|
+
|
|
19
|
+
// Trust level boost (verified/official/trusted rank higher)
|
|
20
|
+
const re = db.prepare('SELECT trust_level, popularity FROM registry_entries WHERE id = ?').get(id);
|
|
21
|
+
if (re) {
|
|
22
|
+
boost *= (TRUST_LEVEL_BOOST[re.trust_level] || 1.0);
|
|
23
|
+
// Popularity bump: top 20% of skills get +5%, rest unchanged
|
|
24
|
+
if (re.popularity > 0) {
|
|
25
|
+
const top20 = db.prepare('SELECT popularity FROM registry_entries ORDER BY popularity DESC LIMIT 1').get();
|
|
26
|
+
if (top20 && re.popularity >= top20.popularity * 0.2) {
|
|
27
|
+
boost *= 1.05;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return score * boost;
|
|
18
33
|
}
|
|
19
34
|
|
|
20
35
|
function normalizeBM25(raw) {
|
|
@@ -114,13 +129,13 @@ export async function search(query, topK = 5) {
|
|
|
114
129
|
if (rerankerEnabled) {
|
|
115
130
|
const { Reranker } = await import('./src/reranker/reranker.js')
|
|
116
131
|
const reranker = new Reranker()
|
|
117
|
-
const topN = ordered.slice(0,
|
|
132
|
+
const topN = ordered.slice(0, Math.max(50, topK * 6))
|
|
118
133
|
.map(({ id, score }) => {
|
|
119
134
|
const s = skillWithSnippet(db, id, score)
|
|
120
135
|
return s ? { id, text: s.snippet, score } : null
|
|
121
136
|
})
|
|
122
137
|
.filter(Boolean)
|
|
123
|
-
const reranked = await reranker.rerank(query, topN)
|
|
138
|
+
const reranked = await reranker.rerank(query, topN, topK)
|
|
124
139
|
return reranked.map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score))).filter(Boolean)
|
|
125
140
|
}
|
|
126
141
|
|
package/src/reranker/reranker.js
CHANGED
|
@@ -1,28 +1,92 @@
|
|
|
1
|
+
// Term-overlap reranker with phrase matching and IDF-like weighting.
|
|
2
|
+
// Replace `rerank()` with a cross-encoder (BGE Reranker / MiniLM via ONNX)
|
|
3
|
+
// for deeper semantic reranking. This class is the plug-in point.
|
|
1
4
|
export class Reranker {
|
|
2
5
|
constructor(modelName = 'default', device = 'cpu') {
|
|
3
6
|
this.modelName = modelName
|
|
4
7
|
this.device = device
|
|
5
8
|
}
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
// This is a lightweight reranker that ranks by term overlap ratio.
|
|
9
|
-
// Replace with BGE Reranker / MiniLM cross-encoder via ONNX for deeper semantic reranking.
|
|
10
|
-
async rerank(query, results) {
|
|
10
|
+
rerank(query, results, topK = 5) {
|
|
11
11
|
if (!results || results.length === 0) return []
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
|
|
13
|
+
const rawTerms = (query.toLowerCase().match(/\w+/g) || [])
|
|
14
|
+
if (rawTerms.length === 0) return results.slice(0, topK)
|
|
15
15
|
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const unigrams = rawTerms
|
|
17
|
+
const bigrams = []
|
|
18
|
+
const trigrams = []
|
|
19
|
+
for (let i = 0; i < rawTerms.length - 1; i++) bigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1])
|
|
20
|
+
for (let i = 0; i < rawTerms.length - 2; i++) trigrams.push(rawTerms[i] + ' ' + rawTerms[i + 1] + ' ' + rawTerms[i + 2])
|
|
21
|
+
|
|
22
|
+
const texts = results.map(r => (r.text || '').toLowerCase())
|
|
23
|
+
|
|
24
|
+
// Term doc-frequency
|
|
25
|
+
const termDf = {}
|
|
26
|
+
for (const t of unigrams) {
|
|
27
|
+
termDf[t] = texts.filter(txt => txt.includes(t)).length
|
|
28
|
+
}
|
|
29
|
+
const nResults = results.length
|
|
30
|
+
|
|
31
|
+
// First pass: compute raw tfIdf for each result
|
|
32
|
+
const tfIdfValues = texts.map(text => {
|
|
33
|
+
let sum = 0
|
|
34
|
+
for (const term of unigrams) {
|
|
35
|
+
const df = termDf[term] || 1
|
|
36
|
+
const idf = 1 + Math.log10((nResults + 1) / df)
|
|
37
|
+
let idx = -1
|
|
38
|
+
let count = 0
|
|
39
|
+
while ((idx = text.indexOf(term, idx + 1)) !== -1) count++
|
|
40
|
+
sum += count * idf
|
|
41
|
+
}
|
|
42
|
+
return sum
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
const maxTfIdf = Math.max(...tfIdfValues, 1)
|
|
46
|
+
|
|
47
|
+
const scored = results.map((r, idx) => {
|
|
48
|
+
const text = texts[idx]
|
|
49
|
+
const lines = text.split('\n')
|
|
50
|
+
|
|
51
|
+
// 1. N-gram overlap
|
|
52
|
+
const unigramMatch = unigrams.filter(t => text.includes(t)).length / unigrams.length
|
|
53
|
+
const bigramMatch = bigrams.length > 0 ? bigrams.filter(b => text.includes(b)).length / bigrams.length : 0
|
|
54
|
+
const trigramMatch = trigrams.length > 0 ? trigrams.filter(t => text.includes(t)).length / trigrams.length : 0
|
|
55
|
+
const ngramScore = 0.4 * unigramMatch + 0.35 * bigramMatch + 0.25 * trigramMatch
|
|
56
|
+
|
|
57
|
+
// 2. IDF-weighted TF (normalised by max across results)
|
|
58
|
+
const tfIdfScore = tfIdfValues[idx] / maxTfIdf
|
|
59
|
+
|
|
60
|
+
// 3. Exact phrase proximity — consecutive same-order term matches
|
|
61
|
+
let phraseBoost = 0
|
|
62
|
+
if (rawTerms.length >= 2) {
|
|
63
|
+
const textWords = text.split(/\s+/)
|
|
64
|
+
let bestRun = 0
|
|
65
|
+
for (let i = 0; i < textWords.length - rawTerms.length + 1; i++) {
|
|
66
|
+
let matchLen = 0
|
|
67
|
+
for (let j = 0; j < rawTerms.length; j++) {
|
|
68
|
+
if (textWords[i + j] === rawTerms[j]) matchLen++
|
|
69
|
+
else break
|
|
70
|
+
}
|
|
71
|
+
if (matchLen >= 2) bestRun = Math.max(bestRun, matchLen)
|
|
72
|
+
}
|
|
73
|
+
phraseBoost = bestRun / rawTerms.length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 4. Header position boost
|
|
77
|
+
const headerText = lines.slice(0, 3).join(' ').toLowerCase()
|
|
78
|
+
const headerOverlap = unigrams.filter(t => headerText.includes(t)).length / unigrams.length
|
|
79
|
+
|
|
80
|
+
// Blend
|
|
81
|
+
const overlapScore = 0.25 * ngramScore + 0.25 * tfIdfScore + 0.25 * phraseBoost + 0.15 * headerOverlap + 0.10 * unigramMatch
|
|
19
82
|
|
|
20
83
|
return {
|
|
21
|
-
|
|
22
|
-
|
|
84
|
+
id: r.id,
|
|
85
|
+
text: r.text,
|
|
86
|
+
score: 0.70 * (r.score || 0) + 0.30 * overlapScore,
|
|
23
87
|
}
|
|
24
88
|
})
|
|
25
89
|
|
|
26
|
-
return scored.sort((a, b) => b.score - a.score).slice(0,
|
|
90
|
+
return scored.sort((a, b) => b.score - a.score).slice(0, topK)
|
|
27
91
|
}
|
|
28
92
|
}
|
package/src/store/index.js
CHANGED
|
@@ -7,7 +7,7 @@ export function getStore(type = null) {
|
|
|
7
7
|
if (_store) return _store;
|
|
8
8
|
|
|
9
9
|
if (!type) {
|
|
10
|
-
type = process.env.PG_VECTOR_STORE || '
|
|
10
|
+
type = process.env.PG_VECTOR_STORE || 'hnsw';
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
_store = type === 'hnsw' ? new HNSWVectorStore() : new FlatVectorStore();
|