promptgraph-mcp 2.4.6 → 2.4.8

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/search.js CHANGED
@@ -2,6 +2,26 @@ import { embed, cosineSimilarity } from './embedder.js';
2
2
  import { getDb, blobToVec } from './db.js';
3
3
  import { annSearch } from './ann.js';
4
4
 
5
+ let embedWeight = 0.7;
6
+ let bm25Weight = 0.3;
7
+
8
+ export function setHybridWeights(ew, bw) {
9
+ embedWeight = ew;
10
+ bm25Weight = bw;
11
+ }
12
+
13
+ function adaptWeights(query) {
14
+ // Technical terms (caps, digits) → BM25 matters more
15
+ const hasTechTerms = /[A-Z]|\d/.test(query);
16
+ if (hasTechTerms) {
17
+ embedWeight = 0.5;
18
+ bm25Weight = 0.5;
19
+ } else {
20
+ embedWeight = 0.7;
21
+ bm25Weight = 0.3;
22
+ }
23
+ }
24
+
5
25
  function applyRatingBoost(db, id, score) {
6
26
  const r = db.prepare('SELECT success, fail FROM ratings WHERE skill_id = ?').get(id);
7
27
  if (r && (r.success + r.fail) > 3) {
@@ -11,13 +31,13 @@ function applyRatingBoost(db, id, score) {
11
31
  return score;
12
32
  }
13
33
 
14
- export async function search(query, topK = 5) {
15
- const db = getDb();
16
- const queryVec = await embed(query);
17
-
18
- // Try ANN index first (fast, O(log N))
19
- const annResults = await annSearch(queryVec, topK * 4);
34
+ function normalizeBM25(raw) {
35
+ return Math.max(0, 1 + raw / 10);
36
+ }
20
37
 
38
+ async function runEmbeddingSearch(db, queryVec, topK) {
39
+ // Try ANN index first (fast)
40
+ const annResults = await annSearch(queryVec, topK);
21
41
  if (annResults && annResults.length > 0) {
22
42
  const bestBySkill = new Map();
23
43
  for (const r of annResults) {
@@ -25,11 +45,8 @@ export async function search(query, topK = 5) {
25
45
  if (!prev || r.score > prev) bestBySkill.set(r.skill_id, r.score);
26
46
  }
27
47
  return [...bestBySkill.entries()]
28
- .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
29
- .sort((a, b) => b.score - a.score)
30
- .slice(0, topK)
31
- .map(({ id, score }) => skillWithSnippet(db, id, score))
32
- .filter(Boolean);
48
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
49
+ .sort((a, b) => b.score - a.score);
33
50
  }
34
51
 
35
52
  // Fallback: brute force cosine (used before first reindex)
@@ -42,14 +59,14 @@ export async function search(query, topK = 5) {
42
59
  if (!prev || score > prev) bestBySkill.set(chunk.skill_id, score);
43
60
  }
44
61
  return [...bestBySkill.entries()]
45
- .map(([id, score]) => ({ id, score: applyRatingBoost(db, id, score) }))
46
- .sort((a, b) => b.score - a.score)
47
- .slice(0, topK)
48
- .map(({ id, score }) => skillWithSnippet(db, id, score))
49
- .filter(Boolean);
62
+ .map(([id, score]) => ({ id, score: Math.max(0, score) }))
63
+ .sort((a, b) => b.score - a.score);
50
64
  }
51
65
 
52
- // Fast-mode fallback: FTS5 keyword search (no embeddings)
66
+ return [];
67
+ }
68
+
69
+ function runBM25Search(db, query, topK) {
53
70
  try {
54
71
  const terms = query.replace(/[^\w\s]/g, ' ').trim().split(/\s+/).filter(Boolean).join(' OR ');
55
72
  const rows = db.prepare(
@@ -57,14 +74,61 @@ export async function search(query, topK = 5) {
57
74
  JOIN skills s ON skills_fts.id = s.id
58
75
  WHERE skills_fts MATCH ? ORDER BY score LIMIT ?`
59
76
  ).all(terms, topK);
60
- return rows.map(r => skillWithSnippet(db, r.id, Math.max(0, 1 + r.score / 10))).filter(Boolean);
77
+ return rows.map(r => ({ id: r.id, score: normalizeBM25(r.score) }));
61
78
  } catch {
62
79
  return [];
63
80
  }
64
81
  }
65
82
 
83
+ export async function search(query, topK = 5) {
84
+ const db = getDb();
85
+ adaptWeights(query);
86
+ const queryVec = await embed(query);
87
+
88
+ const embedResults = await runEmbeddingSearch(db, queryVec, topK * 4);
89
+ const bm25Results = runBM25Search(db, query, topK * 4);
90
+
91
+ // Fallback: pure BM25 if embeddings unavailable
92
+ if (!embedResults.length) {
93
+ return bm25Results.slice(0, topK)
94
+ .map(r => skillWithSnippet(db, r.id, applyRatingBoost(db, r.id, r.score)))
95
+ .filter(Boolean);
96
+ }
97
+
98
+ // Fallback: pure embedding if BM25 returns nothing
99
+ if (!bm25Results.length) {
100
+ return embedResults.slice(0, topK)
101
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
102
+ .filter(Boolean);
103
+ }
104
+
105
+ // Hybrid: combine normalized scores from both signals
106
+ const combined = new Map();
107
+ for (const { id, score } of embedResults) {
108
+ combined.set(id, { embedScore: score, bm25Score: 0 });
109
+ }
110
+ for (const { id, score } of bm25Results) {
111
+ const entry = combined.get(id);
112
+ if (entry) {
113
+ entry.bm25Score = score;
114
+ } else {
115
+ combined.set(id, { embedScore: 0, bm25Score: score });
116
+ }
117
+ }
118
+
119
+ return [...combined.entries()]
120
+ .map(([id, s]) => ({
121
+ id,
122
+ score: embedWeight * s.embedScore + bm25Weight * s.bm25Score,
123
+ }))
124
+ .sort((a, b) => b.score - a.score)
125
+ .slice(0, topK)
126
+ .map(({ id, score }) => skillWithSnippet(db, id, applyRatingBoost(db, id, score)))
127
+ .filter(Boolean);
128
+ }
129
+
66
130
  function skillWithSnippet(db, id, score) {
67
- const skill = db.prepare('SELECT id, name, description, path, source, content FROM skills WHERE id = ?').get(id);
131
+ const skill = db.prepare('SELECT id, name, description, path, source, content, version, author, license, updated_at, downloads, verified FROM skills WHERE id = ?').get(id);
68
132
  if (!skill) return null;
69
133
  const { content, ...rest } = skill;
70
134
  const snippet = content?.replace(/^---[\s\S]*?---\n?/, '').trim().slice(0, 400) || '';
@@ -131,5 +195,5 @@ export function getImpact(nameOrId) {
131
195
 
132
196
  export function listAll() {
133
197
  const db = getDb();
134
- return db.prepare('SELECT id, name, description, source FROM skills ORDER BY source, name').all();
198
+ return db.prepare('SELECT id, name, description, source, version, author, license, updated_at, downloads, verified FROM skills ORDER BY source, name').all();
135
199
  }
@@ -0,0 +1,88 @@
1
+ import { cosineSimilarity } from '../../embedder.js';
2
+
3
+ const SKILL_THRESHOLD = 0.35;
4
+ const UNSURE_THRESHOLD = 0.15;
5
+
6
+ const DOC_FIRST_HEADERS = /^(overview|introduction|about|background|welcome|getting started|what is|why |table of contents|toc|foreword|preface|readme)/i;
7
+ const INSTRUCTION_HEADERS = /^#{1,3}\s+(steps?|usage|instructions?|how\s+to|when\s+to\s+use|workflow|process|procedure|example|examples?|commands?|output|result)/i;
8
+ const IMPERATIVE_HEADERS = /\b(run|use|apply|execute|check|debug|fix|create|add|remove|deploy|test|write|generate|analyze|review|refactor|optimize|configure|setup|install|scan|audit|validate|search|find|extract|parse)\b/i;
9
+
10
+ export function getFeatureVector(raw) {
11
+ const lines = raw.split('\n');
12
+ const nonEmpty = lines.filter(l => l.trim());
13
+ const headers = nonEmpty.filter(l => /^#{1,3}\s/.test(l));
14
+ const paragraphs = raw.split(/\n\n+/).filter(p => p.trim() && !p.trim().startsWith('#'));
15
+ const words = raw.toLowerCase().split(/\s+/).filter(w => w.length > 3);
16
+
17
+ const firstHeader = headers[0]?.replace(/^#+\s*/, '') || '';
18
+ const longProse = paragraphs.filter(p => p.split(' ').length > 60 && !/```/.test(p));
19
+ const wordRatio = words.length > 80 ? new Set(words).size / words.length : 1;
20
+
21
+ return [
22
+ Number(raw.length >= 150),
23
+ Number(headers.length >= 1),
24
+ Number(lines.some(l => INSTRUCTION_HEADERS.test(l))),
25
+ Number(headers.some(h => IMPERATIVE_HEADERS.test(h))),
26
+ Number(raw.includes('```') || raw.includes(' ')),
27
+ Number(nonEmpty.some(l => /^\d+\.\s/.test(l))),
28
+ Number(nonEmpty.some(l => /^[-*+]\s/.test(l))),
29
+ Number(headers.length >= 2),
30
+ Number(headers.length >= 4),
31
+ Number(DOC_FIRST_HEADERS.test(firstHeader)) ? 1 : 0,
32
+ Number(longProse.length > paragraphs.length * 0.6 && paragraphs.length > 3) ? 1 : 0,
33
+ Number(wordRatio >= 0.22) ? 0 : 1,
34
+ Number(raw.length < 150) ? 1 : 0,
35
+ Number(headers.length < 1) ? 1 : 0,
36
+ ];
37
+ }
38
+
39
+ /**
40
+ * Rule A override: if centroid says reject/unsure but file clearly looks like
41
+ * a skill by content (code + instructions + list + verbs, no SKILL in name/path),
42
+ * override to skill. Catches adversarial wrong-heading cases with 0 FP cost.
43
+ */
44
+ function ruleA(rawVec, raw, filePath) {
45
+ const fv = getFeatureVector(raw);
46
+ const hasCode = fv[4];
47
+ const hasInstructions = fv[2];
48
+ const hasNumberedList = fv[5];
49
+ const hasVerb = fv[3];
50
+ const filename = filePath ? filePath.split(/[/\\]/).pop().replace(/\.md$/i, '').toUpperCase() : '';
51
+ const pathUpper = filePath ? filePath.toUpperCase() : '';
52
+ const hasSkillName = filename.includes('SKILL');
53
+ const hasSkillPath = /SKILL/.test(pathUpper);
54
+
55
+ if (hasCode && hasInstructions && hasNumberedList && hasVerb && !hasSkillName && !hasSkillPath) {
56
+ return { label: 'skill', score: 0.6, method: 'ruleA' };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ export function classify(rawVec, centroids, raw, filePath) {
62
+ if (!centroids) {
63
+ return { label: 'skill', score: 1, method: 'fallback' };
64
+ }
65
+
66
+ const goodSim = centroids.good ? cosineSimilarity(rawVec, centroids.good) : 0;
67
+ const badSim = centroids.bad ? cosineSimilarity(rawVec, centroids.bad) : 0;
68
+
69
+ const rawScore = goodSim - badSim;
70
+ const score = (rawScore + 1) / 2;
71
+
72
+ if (score >= SKILL_THRESHOLD) {
73
+ return { label: 'skill', score, goodSim, badSim, method: 'centroid' };
74
+ }
75
+
76
+ // Below threshold — try Rule A content override
77
+ if (raw && filePath) {
78
+ const override = ruleA(rawVec, raw, filePath);
79
+ if (override) return override;
80
+ }
81
+
82
+ if (score >= UNSURE_THRESHOLD) {
83
+ return { label: 'unsure', score, goodSim, badSim, method: 'centroid' };
84
+ }
85
+ return { label: 'reject', score, goodSim, badSim, method: 'centroid' };
86
+ }
87
+
88
+ export { SKILL_THRESHOLD, UNSURE_THRESHOLD };
@@ -0,0 +1,52 @@
1
+ import { cosineSimilarity } from '../../embedder.js';
2
+
3
+ const INFERENCE_THRESHOLD = 0.35;
4
+
5
+ export function findClusters(skills, embeddings) {
6
+ if (!skills.length || !embeddings) return [];
7
+ const clusters = [];
8
+ const assigned = new Set();
9
+
10
+ for (let i = 0; i < skills.length; i++) {
11
+ if (assigned.has(i)) continue;
12
+ const cluster = { centroid: embeddings[i], members: [skills[i]], indices: [i] };
13
+ assigned.add(i);
14
+
15
+ for (let j = i + 1; j < skills.length; j++) {
16
+ if (assigned.has(j)) continue;
17
+ if (cosineSimilarity(embeddings[i], embeddings[j]) >= INFERENCE_THRESHOLD) {
18
+ cluster.members.push(skills[j]);
19
+ cluster.indices.push(j);
20
+ assigned.add(j);
21
+ }
22
+ }
23
+
24
+ clusters.push(cluster);
25
+ }
26
+
27
+ return clusters;
28
+ }
29
+
30
+ export function expandResults(annResults, db) {
31
+ if (!annResults || annResults.length === 0) return annResults;
32
+
33
+ const seenIds = new Set();
34
+ const expanded = [];
35
+
36
+ for (const r of annResults) {
37
+ if (!seenIds.has(r.skill_id)) {
38
+ seenIds.add(r.skill_id);
39
+ expanded.push(r);
40
+ }
41
+
42
+ const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(r.skill_id);
43
+ for (const c of callees) {
44
+ if (!seenIds.has(c.to_skill)) {
45
+ seenIds.add(c.to_skill);
46
+ expanded.push({ skill_id: c.to_skill, score: r.score * 0.85 });
47
+ }
48
+ }
49
+ }
50
+
51
+ return expanded.sort((a, b) => b.score - a.score);
52
+ }
@@ -0,0 +1,36 @@
1
+ import { cosineSimilarity } from '../../embedder.js';
2
+
3
+ const DEDUP_THRESHOLD = 0.97;
4
+
5
+ export function dedup(skills, embeddings) {
6
+ if (!skills.length) return { skills: [], embeddings: [] };
7
+ if (embeddings && embeddings.length !== skills.length) {
8
+ throw new Error('embeddings length must match skills length');
9
+ }
10
+
11
+ const keep = [true]; // first skill always kept
12
+ const kept = [0];
13
+
14
+ for (let i = 1; i < skills.length; i++) {
15
+ let dup = false;
16
+ if (embeddings) {
17
+ for (const j of kept) {
18
+ if (cosineSimilarity(embeddings[i], embeddings[j]) >= DEDUP_THRESHOLD) {
19
+ dup = true;
20
+ break;
21
+ }
22
+ }
23
+ }
24
+ if (dup) {
25
+ keep.push(false);
26
+ } else {
27
+ keep.push(true);
28
+ kept.push(i);
29
+ }
30
+ }
31
+
32
+ return {
33
+ skills: skills.filter((_, i) => keep[i]),
34
+ embeddings: embeddings ? embeddings.filter((_, i) => keep[i]) : undefined,
35
+ };
36
+ }
@@ -0,0 +1,59 @@
1
+ import fs from 'fs';
2
+
3
+ const SKIP_FILENAMES = new Set([
4
+ 'readme', 'changelog', 'license', 'contributing', 'code-of-conduct',
5
+ 'security', 'authors', 'credits', 'install', 'installation', 'usage',
6
+ 'engagements', 'contributors', 'maintainers', 'acknowledgements',
7
+ 'faq', 'glossary', 'index', 'overview', 'summary', 'roadmap', 'todo',
8
+ 'notes', 'template', 'example', 'sample', 'demo', 'getting-started',
9
+ 'quickstart', 'guide', 'tutorial', 'walkthrough', 'architecture',
10
+ 'design', 'spec', 'specification', 'requirements', 'privacy', 'terms',
11
+ 'disclaimer', 'notice', 'copying', 'warranty', 'codeofconduct',
12
+ 'pull_request_template', 'issue_template', 'funding',
13
+ ]);
14
+
15
+ const SKIP_FILENAME_RE = /^(_|\.)|^v?\d+[\.\-]\d+|^\d{4}[\-_]\d{2}|^readme|^license|^changelog|^contributing|^code.of.conduct|^security|^authors|^credits|^disclaimer|^notice|^copying|^warranty|^promotion|^funding/i;
16
+
17
+ const SKIP_DIRS = new Set([
18
+ '.github', 'docs', 'doc', 'documentation', 'examples', 'example',
19
+ 'tests', 'test', '__tests__', 'spec', 'fixtures', 'assets', 'images',
20
+ 'img', 'screenshots', 'media', 'static', 'public', 'dist', 'build',
21
+ 'node_modules', 'vendor', 'third_party',
22
+ 'references', 'reference', 'refs', 'cheatsheet', 'cheat-sheet',
23
+ 'cheatsheets', 'resources',
24
+ ]);
25
+
26
+ const BADGE_RE = /!\[.*\]\(https?:\/\/(img\.shields\.io|badge\.fury|travis-ci|github\.com\/[^)]+\/badge)/i;
27
+ const README_HEADER_RE = /^#\s*readme\b/i;
28
+
29
+ export function hardFilter(filePath, raw) {
30
+ const parts = filePath.replace(/\\/g, '/').split('/');
31
+ const filename = parts[parts.length - 1];
32
+ const base = filename.replace(/\.md$/i, '').toLowerCase();
33
+
34
+ if (SKIP_FILENAMES.has(base)) {
35
+ return { pass: false, reason: `skip filename: ${base}` };
36
+ }
37
+
38
+ if (SKIP_FILENAME_RE.test(base)) {
39
+ return { pass: false, reason: `skip filename pattern: ${base}` };
40
+ }
41
+
42
+ for (const part of parts.slice(0, -1)) {
43
+ if (SKIP_DIRS.has(part.toLowerCase())) {
44
+ return { pass: false, reason: `skip dir: ${part}` };
45
+ }
46
+ }
47
+
48
+ if (raw) {
49
+ const firstLines = raw.trimStart().slice(0, 300);
50
+ if (README_HEADER_RE.test(firstLines)) {
51
+ return { pass: false, reason: 'starts with # Readme' };
52
+ }
53
+ if (BADGE_RE.test(firstLines)) {
54
+ return { pass: false, reason: 'badge-only content' };
55
+ }
56
+ }
57
+
58
+ return { pass: true };
59
+ }
@@ -0,0 +1,66 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { globSync } from 'glob';
6
+ import { embedBatch } from '../../embedder.js';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const TRAINING_DIR = path.resolve(__dirname, '../../registry/training');
10
+ const MODEL_PATH = path.join(os.homedir(), '.claude', '.promptgraph', 'model.json');
11
+
12
+ function readAllMd(dir) {
13
+ const files = globSync(`${dir}/**/*.md`);
14
+ return files.map(f => fs.readFileSync(f, 'utf8'));
15
+ }
16
+
17
+ function meanVector(vectors) {
18
+ if (!vectors.length) return null;
19
+ const dim = vectors[0].length;
20
+ const centroid = new Float32Array(dim);
21
+ for (const v of vectors) {
22
+ for (let i = 0; i < dim; i++) centroid[i] += v[i];
23
+ }
24
+ for (let i = 0; i < dim; i++) centroid[i] /= vectors.length;
25
+ return Array.from(centroid);
26
+ }
27
+
28
+ export async function train() {
29
+ const goodDir = path.join(TRAINING_DIR, 'good');
30
+ const badDir = path.join(TRAINING_DIR, 'bad');
31
+
32
+ const goodTexts = readAllMd(goodDir);
33
+ const badTexts = readAllMd(badDir);
34
+
35
+ if (goodTexts.length < 1 || badTexts.length < 1) {
36
+ throw new Error(`Need at least 1 good and 1 bad training example (good: ${goodTexts.length}, bad: ${badTexts.length})`);
37
+ }
38
+
39
+ const allTexts = [...goodTexts, ...badTexts];
40
+ const allVecs = await embedBatch(allTexts);
41
+
42
+ const goodVecs = allVecs.slice(0, goodTexts.length);
43
+ const badVecs = allVecs.slice(goodTexts.length);
44
+
45
+ const model = {
46
+ version: 1,
47
+ good: meanVector(goodVecs),
48
+ bad: meanVector(badVecs),
49
+ counts: { good: goodTexts.length, bad: badTexts.length },
50
+ };
51
+
52
+ fs.mkdirSync(path.dirname(MODEL_PATH), { recursive: true });
53
+ fs.writeFileSync(MODEL_PATH, JSON.stringify(model));
54
+ return model;
55
+ }
56
+
57
+ export function loadModel() {
58
+ try {
59
+ const raw = fs.readFileSync(MODEL_PATH, 'utf8');
60
+ return JSON.parse(raw);
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ export { MODEL_PATH };
@@ -0,0 +1,61 @@
1
+ import { VectorStore } from './vector-store.js';
2
+
3
+ export class FlatVectorStore extends VectorStore {
4
+ constructor() {
5
+ super();
6
+ this._items = [];
7
+ }
8
+
9
+ async add(id, vector) {
10
+ this._items.push({ skill_id: id, vec: new Float32Array(vector) });
11
+ }
12
+
13
+ async addBatch(entries) {
14
+ for (const { id, vector } of entries) {
15
+ this._items.push({ skill_id: id, vec: new Float32Array(vector) });
16
+ }
17
+ }
18
+
19
+ async remove(id) {
20
+ this._items = this._items.filter(item => item.skill_id !== id);
21
+ }
22
+
23
+ async search(vector, topK = 20) {
24
+ const qArr = new Float32Array(vector);
25
+ const bestBySkill = new Map();
26
+ for (const entry of this._items) {
27
+ const score = cosineSim(qArr, entry.vec);
28
+ const prev = bestBySkill.get(entry.skill_id);
29
+ if (!prev || score > prev) bestBySkill.set(entry.skill_id, score);
30
+ }
31
+ return [...bestBySkill.entries()]
32
+ .sort((a, b) => b[1] - a[1])
33
+ .slice(0, topK)
34
+ .map(([skill_id, score]) => ({ skill_id, score }));
35
+ }
36
+
37
+ async build(entries) {
38
+ this._items = [];
39
+ for (const { skill_id, vector } of entries) {
40
+ this._items.push({ skill_id, vec: new Float32Array(vector) });
41
+ }
42
+ }
43
+
44
+ async clear() {
45
+ this._items = [];
46
+ }
47
+
48
+ get size() {
49
+ return this._items.length;
50
+ }
51
+ }
52
+
53
+ function cosineSim(a, b) {
54
+ let dot = 0, na = 0, nb = 0;
55
+ for (let i = 0; i < a.length; i++) {
56
+ dot += a[i] * b[i];
57
+ na += a[i] * a[i];
58
+ nb += b[i] * b[i];
59
+ }
60
+ return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-8);
61
+ }