promptgraph-mcp 2.9.62 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,6 +77,10 @@ pg update # Update promptgraph-mcp to latest version
77
77
  pg search <query> # Semantic search across your skills
78
78
  pg marketplace # Browse community skill bundles (TUI)
79
79
 
80
+ # Skill graph
81
+ pg plan <skill> # DAG execution plan: order, parallel levels, cycle detection
82
+ pg duplicates # Find near-duplicate / overlapping skills
83
+
80
84
  # Install
81
85
  pg install <id> # Install a skill by marketplace ID (pg-xxxxxx)
82
86
  pg import <github-url> # Import skills directly from a GitHub repo URL
@@ -0,0 +1,41 @@
1
+ import { success, error, info } from '../cli.js';
2
+ import chalk from 'chalk';
3
+
4
+ // pg duplicates [--threshold 0.92] [--json] — flag near-duplicate / overlapping skills.
5
+ export default async function handler(args, bin) {
6
+ const ti = args.indexOf('--threshold');
7
+ const threshold = ti !== -1 && args[ti + 1] ? parseFloat(args[ti + 1]) : 0.70;
8
+ const asJson = args.includes('--json');
9
+
10
+ const spin = (await import('../cli.js')).spinner('Comparing skill embeddings...');
11
+ spin.start();
12
+ let pairs;
13
+ try {
14
+ const { findDuplicates } = await import('../duplicates.js');
15
+ pairs = await findDuplicates({ threshold });
16
+ } catch (e) {
17
+ spin.stop();
18
+ error(`Duplicate scan failed: ${e.message}`);
19
+ process.exit(1);
20
+ }
21
+ spin.stop();
22
+
23
+ if (asJson) { console.log(JSON.stringify(pairs, null, 2)); process.exit(0); }
24
+
25
+ if (!pairs.length) {
26
+ success(`No near-duplicate skills found above similarity ${threshold}.`);
27
+ process.exit(0);
28
+ }
29
+
30
+ console.log('\n' + chalk.bold(`${pairs.length} near-duplicate pair${pairs.length === 1 ? '' : 's'}`) +
31
+ chalk.gray(` (cosine ≥ ${threshold})\n`));
32
+ for (const p of pairs) {
33
+ const simColor = p.sim >= 0.73 ? chalk.red : p.sim >= 0.71 ? chalk.yellow : chalk.gray;
34
+ const cross = p.sameSource ? '' : chalk.gray(' (different sources)');
35
+ console.log(` ${simColor(p.sim.toFixed(3))} ${chalk.cyan(p.aName)} ${chalk.gray('≈')} ${chalk.cyan(p.bName)}${cross}`);
36
+ console.log(` ${chalk.gray(p.a)}`);
37
+ console.log(` ${chalk.gray(p.b)}`);
38
+ }
39
+ console.log(chalk.gray('\n Review pairs ≥ 0.73 first — likely true duplicates worth merging or removing.\n'));
40
+ process.exit(0);
41
+ }
@@ -0,0 +1,45 @@
1
+ import { success, error, info } from '../cli.js';
2
+ import chalk from 'chalk';
3
+
4
+ // pg plan <skill> — show the DAG execution plan for a goal skill.
5
+ export default async function handler(args, bin) {
6
+ const target = args.slice(1).filter(a => !a.startsWith('-')).join(' ');
7
+ if (!target) { error('Usage: pg plan <skill-id-or-name>'); process.exit(1); }
8
+ const asJson = args.includes('--json');
9
+
10
+ const { buildPlan } = await import('../planner.js');
11
+ const plan = buildPlan(target);
12
+ if (plan?.error) { error(plan.error); process.exit(1); }
13
+
14
+ if (asJson) { console.log(JSON.stringify(plan, null, 2)); process.exit(0); }
15
+
16
+ const name = (id) => plan.nodes[id]?.name || id;
17
+
18
+ console.log('\n' + chalk.bold(`Plan for ${chalk.cyan(plan.root.name)}`) +
19
+ chalk.gray(` (${plan.count} skill${plan.count === 1 ? '' : 's'})`));
20
+
21
+ if (!plan.acyclic) {
22
+ console.log(chalk.red(`\n ✗ Cycle detected — cannot execute as-is:`));
23
+ for (const cyc of plan.cycles) {
24
+ console.log(' ' + chalk.red(cyc.map(name).join(' → ')));
25
+ }
26
+ console.log(chalk.gray(' Break the cycle (a skill ends up depending on itself).'));
27
+ }
28
+
29
+ if (plan.unresolved.length) {
30
+ console.log(chalk.yellow(`\n ⚠ Referenced but not indexed (${plan.unresolved.length}):`));
31
+ for (const u of plan.unresolved) console.log(' ' + chalk.yellow(u));
32
+ }
33
+
34
+ if (plan.acyclic && plan.levels.length) {
35
+ console.log(chalk.bold('\n Execution order ') + chalk.gray('(dependencies first; each level can run in parallel):'));
36
+ plan.levels.forEach((batch, i) => {
37
+ const tag = batch.length > 1 ? chalk.gray(` (${batch.length} parallel)`) : '';
38
+ console.log(` ${chalk.gray(`L${i}`)} ${batch.map(id => chalk.white(name(id))).join(chalk.gray(' · '))}${tag}`);
39
+ });
40
+ console.log(chalk.gray('\n Linear order: ') + plan.order.map(name).join(chalk.gray(' → ')));
41
+ }
42
+
43
+ console.log();
44
+ process.exit(0);
45
+ }
package/duplicates.js ADDED
@@ -0,0 +1,70 @@
1
+ import { getDb, blobToVec } from './db.js';
2
+ import { cosineSimilarity } from './embedder.js';
3
+ import { annSearch, buildAnnIndex } from './ann.js';
4
+ import { getStore } from './src/store/index.js';
5
+
6
+ // ── Near-duplicate / overlap detector ────────────────────────────────────────
7
+ // With ~2000 skills, overlap and redundancy are real. Represent each skill by the
8
+ // mean of its chunk embeddings, use the ANN index to get candidate neighbors
9
+ // (avoids O(n²)), then confirm with exact cosine on the skill-level vectors.
10
+
11
+ function skillMeanVectors(db) {
12
+ const rows = db.prepare('SELECT skill_id, embedding FROM chunks').all();
13
+ const acc = new Map(); // skill_id -> { vec: Float32Array, n }
14
+ for (const r of rows) {
15
+ const v = blobToVec(r.embedding);
16
+ const cur = acc.get(r.skill_id);
17
+ if (!cur) acc.set(r.skill_id, { vec: Float32Array.from(v), n: 1 });
18
+ else { for (let i = 0; i < v.length; i++) cur.vec[i] += v[i]; cur.n++; }
19
+ }
20
+ const out = new Map();
21
+ for (const [id, { vec, n }] of acc) {
22
+ for (let i = 0; i < vec.length; i++) vec[i] /= n;
23
+ out.set(id, vec);
24
+ }
25
+ return out;
26
+ }
27
+
28
+ // Calibrated to BGE-Small mean-pooled vectors, whose similarity scale is compressed:
29
+ // genuine near-duplicates land around 0.70–0.73, not 0.9+. Default 0.70 surfaces real
30
+ // overlap; 0.73+ is near-identical content.
31
+ export async function findDuplicates({ threshold = 0.70, maxPairs = 200, neighbors = 8 } = {}) {
32
+ const db = getDb();
33
+ // A fresh CLI process has an empty in-memory ANN store — build it from chunks
34
+ // so neighbor lookup works (the MCP server keeps it warm; the CLI must not assume that).
35
+ if (getStore().size === 0) await buildAnnIndex();
36
+ const vecs = skillMeanVectors(db);
37
+ const meta = new Map(
38
+ db.prepare('SELECT id, name, source FROM skills').all().map(s => [s.id, s])
39
+ );
40
+
41
+ const pairs = [];
42
+ const seen = new Set();
43
+
44
+ for (const [id, vec] of vecs) {
45
+ const hits = await annSearch(vec, neighbors);
46
+ if (!hits) break; // ANN unavailable
47
+ for (const { skill_id: other } of hits) {
48
+ if (other === id) continue;
49
+ const key = id < other ? `${id}|${other}` : `${other}|${id}`;
50
+ if (seen.has(key)) continue;
51
+ const ov = vecs.get(other);
52
+ if (!ov) continue;
53
+ const sim = cosineSimilarity(vec, ov);
54
+ if (sim >= threshold) {
55
+ seen.add(key);
56
+ const a = meta.get(id), b = meta.get(other);
57
+ pairs.push({
58
+ a: id, b: other,
59
+ aName: a?.name || id, bName: b?.name || other,
60
+ aSource: a?.source || '?', bSource: b?.source || '?',
61
+ sameSource: a?.source === b?.source,
62
+ sim: +sim.toFixed(4),
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ pairs.sort((x, y) => y.sim - x.sim);
69
+ return pairs.slice(0, maxPairs);
70
+ }
package/index.js CHANGED
@@ -18,7 +18,7 @@ const args = process.argv.slice(2);
18
18
  const rawBin = process.argv[1]?.split(/[\\/]/).pop()?.replace(/\.js$/, '');
19
19
  const bin = (rawBin && rawBin !== 'index') ? rawBin : 'pg';
20
20
 
21
- const KNOWN_COMMANDS = new Set(['reindex', 'update', 'import', 'install', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h', '--version', '-v', 'version', 'bundle', 'status', 'train', 'add-dir']);
21
+ const KNOWN_COMMANDS = new Set(['reindex', 'update', 'import', 'install', 'setup', 'validate', 'marketplace', 'doctor', 'search', 'help', '--help', '-h', '--version', '-v', 'version', 'bundle', 'status', 'train', 'add-dir', 'plan', 'duplicates']);
22
22
 
23
23
  function showHelp() {
24
24
  console.log(
@@ -34,6 +34,8 @@ function showHelp() {
34
34
  ['search <query>', 'Search skills from the terminal'],
35
35
  ['import <owner/repo>', 'Import skills from GitHub'],
36
36
  ['add-dir <path>', 'Index skills from a local folder (any platform)'],
37
+ ['plan <skill>', 'Show the DAG execution plan (order, parallel levels, cycles)'],
38
+ ['duplicates', 'Find near-duplicate / overlapping skills'],
37
39
  ['status', 'Show installed skills, repos, and bundles'],
38
40
  ['install <name>', 'Install a bundle by name, code, or id'],
39
41
  ['marketplace', 'Interactive TUI: browse + search + install skills & bundles'],
@@ -95,6 +97,8 @@ const COMMAND_MAP = {
95
97
  update: './commands/update.js',
96
98
  reindex: './commands/reindex.js',
97
99
  'add-dir': './commands/add-dir.js',
100
+ plan: './commands/plan.js',
101
+ duplicates: './commands/duplicates.js',
98
102
  }
99
103
 
100
104
  if (COMMAND_MAP[args[0]]) {
@@ -174,6 +178,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
174
178
  required: ['name'],
175
179
  },
176
180
  },
181
+ {
182
+ name: 'pg_plan',
183
+ description: 'Build a DAG execution plan for a goal skill: full transitive dependency graph, topological order (dependencies first), parallelizable levels, cycle detection, and unresolved references. Call before chaining to know the whole plan up front.',
184
+ inputSchema: {
185
+ type: 'object',
186
+ properties: { name: { type: 'string' } },
187
+ required: ['name'],
188
+ },
189
+ },
177
190
  {
178
191
  name: 'pg_rate',
179
192
  description: 'Record skill usage outcome. Call after applying a skill: outcome="success" if it helped, "fail" if it did not.',
@@ -288,6 +301,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
288
301
  case 'pg_callers': result = getCallers(args.name); break;
289
302
  case 'pg_callees': result = getCallees(args.name); break;
290
303
  case 'pg_impact': result = getImpact(args.name); break;
304
+ case 'pg_plan': { const { buildPlan } = await import('./planner.js'); result = buildPlan(args.name); break; }
291
305
  case 'pg_rate':
292
306
  if (args.outcome === 'use') recordUse(args.skill_id);
293
307
  else if (args.outcome === 'success') recordSuccess(args.skill_id);
package/indexer.js CHANGED
@@ -99,10 +99,10 @@ export async function indexBatch(db, skills, { fast = false, silent = false, onE
99
99
  for (const skill of skills) {
100
100
  const id = skillId(skill.source, skill.name);
101
101
  for (const calledName of skill.calls) {
102
- // prefer a skill in the same source, fall back to any, then bare name
103
- const same = resolveSameSource.get(calledName, skill.source);
104
- const resolved = same || resolveAny.get(calledName);
105
- upsertEdge.run(id, resolved ? resolved.id : calledName);
102
+ // Only keep an edge if it resolves to a REAL indexed skill otherwise
103
+ // `/var`, `/usr`, `/scan` (paths & command words) pollute the call graph.
104
+ const resolved = resolveSameSource.get(calledName, skill.source) || resolveAny.get(calledName);
105
+ if (resolved && resolved.id !== id) upsertEdge.run(id, resolved.id);
106
106
  }
107
107
  }
108
108
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "2.9.62",
3
+ "version": "2.10.0",
4
4
  "files": [
5
5
  "*.js",
6
6
  "commands/",
package/parser.js CHANGED
@@ -5,7 +5,9 @@ import { hardFilter } from './src/filter/hard-filter.js';
5
5
  import { classify } from './src/filter/classifier.js';
6
6
  import { loadModel } from './src/filter/train.js';
7
7
 
8
- const SKILL_REF_RE = /(?<!https?:|ftp:)(?<![a-zA-Z0-9])\/([a-z0-9][a-z0-9-]{2,})/g;
8
+ // /skill-name reference. Trailing (?![a-z0-9-]|\/) forces the full word and rejects
9
+ // path segments like /var/log, /usr/bin (a slash right after the word → not a skill ref).
10
+ const SKILL_REF_RE = /(?<!https?:|ftp:)(?<![a-zA-Z0-9])\/([a-z0-9][a-z0-9-]{2,})(?![a-z0-9-]|\/)/g;
9
11
 
10
12
  // Generic filenames that carry no identity — name comes from the parent folder.
11
13
  const GENERIC_SKILL_FILENAMES = new Set([
package/planner.js ADDED
@@ -0,0 +1,132 @@
1
+ import { getDb } from './db.js';
2
+
3
+ // ── DAG execution planner ─────────────────────────────────────────────────────
4
+ // Skills reference other skills (edge A→B means "A calls/uses B"). This builds the
5
+ // full transitive dependency graph for a goal skill and produces a deterministic
6
+ // execution plan BEFORE anything runs: topological order, parallelizable levels,
7
+ // cycle detection (with the actual cycle path), and dangling/unresolved refs.
8
+
9
+ function resolveId(db, nameOrId) {
10
+ const byId = db.prepare('SELECT id FROM skills WHERE id = ?').get(nameOrId);
11
+ if (byId) return { id: byId.id };
12
+ const byName = db.prepare('SELECT id FROM skills WHERE name = ?').all(nameOrId);
13
+ if (byName.length === 1) return { id: byName[0].id };
14
+ if (byName.length > 1) {
15
+ return { error: `Ambiguous name "${nameOrId}" — use a full id: ${byName.map(r => r.id).join(', ')}` };
16
+ }
17
+ return { error: `Skill not found: ${nameOrId}` };
18
+ }
19
+
20
+ // Find every cycle reachable in the dependency subgraph (DFS with a recursion stack).
21
+ function findCycles(nodeIds, adj) {
22
+ const WHITE = 0, GRAY = 1, BLACK = 2;
23
+ const color = new Map(nodeIds.map(id => [id, WHITE]));
24
+ const stack = [];
25
+ const cycles = [];
26
+ const seenCycle = new Set();
27
+
28
+ function dfs(u) {
29
+ color.set(u, GRAY);
30
+ stack.push(u);
31
+ for (const v of adj.get(u) || []) {
32
+ if (!color.has(v)) continue; // edge to a node outside the set (unresolved)
33
+ if (color.get(v) === GRAY) {
34
+ // back-edge → cycle from v..u then back to v
35
+ const i = stack.indexOf(v);
36
+ const cycle = stack.slice(i).concat(v);
37
+ const key = [...cycle].sort().join('|');
38
+ if (!seenCycle.has(key)) { seenCycle.add(key); cycles.push(cycle); }
39
+ } else if (color.get(v) === WHITE) {
40
+ dfs(v);
41
+ }
42
+ }
43
+ stack.pop();
44
+ color.set(u, BLACK);
45
+ }
46
+
47
+ for (const id of nodeIds) if (color.get(id) === WHITE) dfs(id);
48
+ return cycles;
49
+ }
50
+
51
+ export function buildPlan(nameOrId) {
52
+ const db = getDb();
53
+ const res = resolveId(db, nameOrId);
54
+ if (res.error) return res;
55
+ const rootId = res.id;
56
+
57
+ // 1. Gather the transitive dependency subgraph (root + everything it reaches via callees).
58
+ const nodes = new Map(); // id -> { id, name, present, callees: [] }
59
+ const adj = new Map(); // id -> [calleeIds]
60
+ const unresolved = new Set();
61
+ const stack = [rootId];
62
+ const seen = new Set();
63
+
64
+ while (stack.length) {
65
+ const cur = stack.pop();
66
+ if (seen.has(cur)) continue;
67
+ seen.add(cur);
68
+ const skill = db.prepare('SELECT id, name, source FROM skills WHERE id = ?').get(cur);
69
+ const present = !!skill;
70
+ const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(cur).map(r => r.to_skill);
71
+ nodes.set(cur, { id: cur, name: skill?.name || cur, source: skill?.source || null, present, callees });
72
+ adj.set(cur, callees);
73
+ if (!present) { unresolved.add(cur); continue; } // dangling ref — don't expand
74
+ for (const c of callees) if (!seen.has(c)) stack.push(c);
75
+ }
76
+
77
+ const nodeIds = [...nodes.keys()];
78
+
79
+ // 2. Cycle detection (report actual paths).
80
+ const cycles = findCycles(nodeIds, adj);
81
+ const acyclic = cycles.length === 0;
82
+
83
+ // 3. Topological order (Kahn) — dependencies (callees) before dependents.
84
+ // indegree[n] = how many of n's callees are still unscheduled.
85
+ const inSet = new Set(nodeIds);
86
+ const indeg = new Map();
87
+ const dependents = new Map(nodeIds.map(id => [id, []])); // callee -> [callers]
88
+ for (const id of nodeIds) {
89
+ const deps = (adj.get(id) || []).filter(c => inSet.has(c));
90
+ indeg.set(id, deps.length);
91
+ for (const d of deps) dependents.get(d).push(id);
92
+ }
93
+
94
+ const order = [];
95
+ const level = new Map();
96
+ let queue = nodeIds.filter(id => indeg.get(id) === 0);
97
+ for (const id of queue) level.set(id, 0);
98
+
99
+ while (queue.length) {
100
+ const next = [];
101
+ for (const d of queue) {
102
+ order.push(d);
103
+ for (const caller of dependents.get(d)) {
104
+ indeg.set(caller, indeg.get(caller) - 1);
105
+ level.set(caller, Math.max(level.get(caller) || 0, (level.get(d) || 0) + 1));
106
+ if (indeg.get(caller) === 0) next.push(caller);
107
+ }
108
+ }
109
+ queue = next;
110
+ }
111
+
112
+ // 4. Parallelizable batches — nodes at the same level have no inter-dependency.
113
+ const levels = [];
114
+ if (acyclic) {
115
+ const maxLevel = Math.max(0, ...[...level.values()]);
116
+ for (let l = 0; l <= maxLevel; l++) {
117
+ const batch = order.filter(id => level.get(id) === l);
118
+ if (batch.length) levels.push(batch);
119
+ }
120
+ }
121
+
122
+ return {
123
+ root: { id: rootId, name: nodes.get(rootId).name },
124
+ count: nodeIds.length,
125
+ acyclic,
126
+ order, // dependencies first, root last
127
+ levels, // each batch can run in parallel
128
+ cycles, // [[a,b,c,a], …] if any
129
+ unresolved: [...unresolved], // referenced but not indexed
130
+ nodes: Object.fromEntries(nodes),
131
+ };
132
+ }