promptgraph-mcp 2.6.0 → 2.6.1

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
@@ -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 cosine similarity over in-memory index
18
- return top skill path + snippet
19
- Claude reads only that file
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, flat cosine search in-memory.
23
- No external vector DB, no API key, no cloud.
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 | **2** |
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 falls back to FTS5 automatically if no embeddings exist. When embeddings are
158
- available, results are a hybrid of semantic similarity (embedding cosine) and keyword
159
- relevance (BM25)giving you the best of both approaches.
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
@@ -1,6 +1,6 @@
1
1
  const CHUNK_SIZE = 800;
2
2
  const CHUNK_OVERLAP = 100;
3
- const MAX_CHUNKS = 2;
3
+ const MAX_CHUNKS = parseInt(process.env.PG_MAX_CHUNKS, 10) || 32;
4
4
 
5
5
  export function chunkText(text) {
6
6
  // Split on markdown h1/h2/h3 headers to preserve semantic boundaries
@@ -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
+ }
@@ -0,0 +1,7 @@
1
+ import { colors, banner, success, error, info, section, table } from '../cli.js';
2
+
3
+ export default async function handler(args, bin) {
4
+ const { importFromGitHub } = await import('../github-import.js');
5
+ await importFromGitHub(args[1]);
6
+ process.exit(0);
7
+ }
@@ -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/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
- function calcPopularity(downloads = 0, rating = 0) {
566
- return Math.round((downloads || 0) * ((rating || 0) + 1) * 100) / 100
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 id').all(level)
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 id').all()
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
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
4
4
  "files": [
5
5
  "*.js",
6
- "src/**/*.js",
6
+ "commands/",
7
+ "src/",
7
8
  "README.md"
8
9
  ],
9
10
  "main": "index.js",
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
- const rating = r.success / (r.success + r.fail);
15
- return score * (0.85 + 0.15 * rating);
16
+ boost *= (0.85 + 0.15 * (r.success / (r.success + r.fail)));
16
17
  }
17
- return score;
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, 20)
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
 
@@ -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
- // Term-overlap boost on top of hybrid search scores
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 queryTerms = (query.toLowerCase().match(/\w+/g) || []).slice(0, 50)
14
- const queryTermCount = queryTerms.length || 1
13
+ const rawTerms = (query.toLowerCase().match(/\w+/g) || [])
14
+ if (rawTerms.length === 0) return results.slice(0, topK)
15
15
 
16
- const scored = results.map(r => {
17
- const text = (r.text || '').toLowerCase()
18
- const termOverlap = queryTerms.filter(t => text.includes(t)).length / queryTermCount
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
- ...r,
22
- score: 0.8 * (r.score || 0) + 0.2 * termOverlap,
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, 5)
90
+ return scored.sort((a, b) => b.score - a.score).slice(0, topK)
27
91
  }
28
92
  }
@@ -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 || 'flat';
10
+ type = process.env.PG_VECTOR_STORE || 'hnsw';
11
11
  }
12
12
 
13
13
  _store = type === 'hnsw' ? new HNSWVectorStore() : new FlatVectorStore();