promptgraph-mcp 1.5.21 → 1.5.23

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
@@ -1,160 +1,170 @@
1
- # PromptGraph
2
-
3
- Semantic skill router for Claude Code. Instead of loading all your skills into context on every request, PromptGraph indexes them with vector embeddings and loads only the relevant one on demand.
4
-
5
- ## The Problem
6
-
7
- Claude Code loads all `.md` files from `~/.claude/commands/` into the system prompt on every session. With 40+ skills, that's **20,000–50,000 tokens wasted per conversation** — before you've even said hello.
8
-
9
- ## The Solution
10
-
11
- ```
12
- ~/.claude/commands/
13
- pg.md ← one tiny router skill (~150 tokens)
14
-
15
- ~/.claude/skills-store/
16
- game-audit.md
17
- chain.md
18
- hunt-sqli.md
19
- ... ← 40+ skills, NOT loaded into context
20
- ```
21
-
22
- When you ask Claude a question, it calls `pg_search("your task")` → finds the right skill via vector search → returns the path. Claude then reads only that file instead of having all skills preloaded in context.
23
-
24
- ## Features
25
-
26
- - **Vector search** via `fastembed` (`BGE-Small-EN`, 23MB, runs locally, no API needed)
27
- - **Semantic matching** — Russian query finds English skill, synonyms work
28
- - **Auto-reindex** via file watcher when skills change
29
- - **Graph edges** — tracks which skills call other skills
30
- - **MCP server** — integrates directly into Claude Code and Claude Desktop
31
-
32
- ## Installation
33
-
34
- ### Via npx (recommended)
35
-
36
- ```bash
37
- npx promptgraph-mcp init
38
- ```
39
-
40
- ### From source
41
-
42
- ```bash
43
- git clone https://github.com/NeiP4n/promptgraph
44
- cd promptgraph
45
- npm install
46
- npm link
47
- promptgraph-mcp init
48
- ```
49
-
50
- `init` will:
51
- 1. Ask for extra skill directories (optional)
52
- 2. Download the embedding model (~23MB, one time)
53
- 3. Index all your skills
54
- 4. Print the config snippet to add to `settings.json`
55
-
56
- ## Setup
57
-
58
- ### Claude Code (`~/.claude/settings.json`)
59
-
60
- ```json
61
- {
62
- "mcpServers": {
63
- "promptgraph": {
64
- "command": "npx",
65
- "args": ["promptgraph-mcp"]
66
- }
67
- }
68
- }
69
- ```
70
-
71
- ### Claude Desktop
72
-
73
- Add the same block to `claude_desktop_config.json`.
74
-
75
- ### Router skill (`~/.claude/commands/pg.md`)
76
-
77
- ```markdown
78
- ---
79
- name: pg
80
- description: PromptGraph router finds and loads the right skill for any task
81
- ---
82
-
83
- # PromptGraph Router
84
-
85
- You have access to a semantic skill index via the `promptgraph` MCP server.
86
-
87
- ## How to handle any task
88
-
89
- 1. Call `pg_search` with the user's task as query (in English)
90
- 2. Pick the top result with score > 0.6
91
- 3. Read the skill file at the returned `path`
92
- 4. Execute that skill's instructions
93
-
94
- ## If no good match (score < 0.6)
95
-
96
- Handle the task directly without a skill.
97
- ```
98
-
99
- Move all your other skills from `commands/` to `skills-store/`:
100
-
101
- ```bash
102
- mkdir -p ~/.claude/skills-store
103
- mv ~/.claude/commands/*.md ~/.claude/skills-store/
104
- mv ~/.claude/skills-store/pg.md ~/.claude/commands/
105
- ```
106
-
107
- ## Commands
108
-
109
- ```bash
110
- promptgraph-mcp init # First-time setup (interactive)
111
- promptgraph-mcp reindex # Re-index all skills
112
- ```
113
-
114
- ## MCP Tools
115
-
116
- | Tool | Description |
117
- |---|---|
118
- | `pg_search` | Semantic search by task description |
119
- | `pg_list` | List all indexed skills |
120
- | `pg_context` | Full details for a skill |
121
- | `pg_callers` | Which skills reference this one |
122
- | `pg_callees` | Which skills this one references |
123
- | `pg_impact` | What breaks if this skill changes |
124
-
125
- ## Token Savings
126
-
127
- | | Before | After |
128
- |---|---|---|
129
- | Skills in context | All 40+ | 1 (router) |
130
- | Tokens per session | ~20,000–50,000 | ~150 + 1 skill |
131
-
132
- > **Search:** Uses HNSW index (via [vectra](https://github.com/Stevenic/vectra)) for O(log N) approximate nearest neighbor search. Falls back to brute-force on first run before index is built.
133
-
134
- ## File Structure
135
-
136
- ```
137
- promptgraph/
138
- index.js ← MCP server + CLI
139
- config.js ← Config management
140
- db.js ← SQLite setup
141
- embedder.js ← fastembed wrapper
142
- indexer.js ← Skill indexer
143
- parser.js ← .md parser
144
- search.js ← Vector search + graph queries
145
- watcher.js ← File watcher (auto-reindex)
146
-
147
- ~/.claude/.promptgraph/
148
- promptgraph.db ← SQLite index
149
- model-cache/ ← Embedding model cache
150
- config.json ← Skill directory config
151
- ```
152
-
153
- ## Requirements
154
-
155
- - Node.js 18+
156
- - Claude Code or Claude Desktop
157
-
158
- ---
159
-
160
- *Generated with [Claude](https://claude.ai) by Anthropic*
1
+ # PromptGraph
2
+
3
+ **Stop burning 50,000 tokens on skills you won't use.**
4
+
5
+ PromptGraph is an MCP server that gives Claude Code a semantic skill index — vector search, skill graph, and a community marketplace. Instead of cramming every `.md` into your system prompt, Claude finds and loads only the one skill it needs.
6
+
7
+ [![npm](https://img.shields.io/npm/v/promptgraph-mcp?color=7C3AED&label=npm)](https://www.npmjs.com/package/promptgraph-mcp)
8
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
9
+
10
+ ---
11
+
12
+ ## Why
13
+
14
+ Claude Code dumps every file in `~/.claude/commands/` into the system prompt — every session, every message. At 40+ skills that's **20,000–50,000 wasted tokens before you say a word.**
15
+
16
+ PromptGraph replaces that with one tiny router skill (`~150 tokens`) and a local vector index. When you ask something, Claude calls `pg_search` → gets the right skill path → reads only that file.
17
+
18
+ ```
19
+ Before: all 40 skills loaded → ~40,000 tokens wasted
20
+ After: 1 router + 1 match → ~300 tokens total
21
+ ```
22
+
23
+ ---
24
+
25
+ ## Features
26
+
27
+ - 🔍 **Semantic search** — finds skills by meaning, not just keywords (HNSW, O(log N))
28
+ - 📦 **Marketplace** browse and install community skills with one command
29
+ - 🧩 **Skill bundles** — install curated packs (e.g. `engineering-essentials`)
30
+ - 🔗 **Dependency graph** — tracks which skills call other skills (`pg_callers`, `pg_impact`)
31
+ - ⚡ **Local embeddings** — `fastembed` BGE-Small-EN, 23 MB, no API key needed
32
+ - 👁️ **File watcher** — auto-reindexes when you add or edit skills
33
+ - 🛡️ **Validator** — blocks malicious/junk skills before they reach your machine
34
+ - 🌐 **MCP-native** — works with Claude Code, Claude Desktop, Cline, and any MCP client
35
+
36
+ ---
37
+
38
+ ## Quick Start
39
+
40
+ ```bash
41
+ # one-time global install (recommended — faster than npx every time)
42
+ npm install -g promptgraph-mcp@latest
43
+ pg init
44
+
45
+ # or without installing
46
+ npx promptgraph-mcp init
47
+ ```
48
+
49
+ `init` downloads the embedding model (~23 MB, once), indexes your skills, and prints the config to paste into `settings.json`.
50
+
51
+ ### Add to Claude Code (`~/.claude/settings.json`)
52
+
53
+ ```json
54
+ {
55
+ "mcpServers": {
56
+ "promptgraph": {
57
+ "command": "npx",
58
+ "args": ["promptgraph-mcp"]
59
+ }
60
+ }
61
+ }
62
+ ```
63
+
64
+ ### Move your skills out of `commands/`
65
+
66
+ ```bash
67
+ mkdir -p ~/.claude/skills-store
68
+ mv ~/.claude/commands/*.md ~/.claude/skills-store/
69
+ mv ~/.claude/skills-store/pg.md ~/.claude/commands/ # keep only the router
70
+ ```
71
+
72
+ ---
73
+
74
+ ## Marketplace
75
+
76
+ Browse and install community skills without leaving your terminal:
77
+
78
+ ```bash
79
+ pg marketplace # browse skills
80
+ pg marketplace bundles # browse bundles
81
+ ```
82
+
83
+ Or ask Claude directly:
84
+
85
+ ```
86
+ install pg-a1b2c3 # by code
87
+ install systematic-debugging # by name
88
+ ```
89
+
90
+ **Publish your own skill:**
91
+
92
+ ```bash
93
+ pg publish ~/.claude/skills-store/my-skill.md
94
+ ```
95
+
96
+ > Skills are validated automatically dangerous patterns, prompt injection, and junk are rejected by CI before they enter the registry.
97
+
98
+ ---
99
+
100
+ ## CLI
101
+
102
+ ```bash
103
+ pg init # first-time setup
104
+ pg reindex # re-index all skills
105
+ pg search "deploy" # search from terminal
106
+ pg list # list all indexed skills
107
+ pg marketplace # browse registry
108
+ pg import owner/repo # import any GitHub repo full of .md skills
109
+ pg validate my-skill.md
110
+ pg doctor # clean up orphaned data
111
+ ```
112
+
113
+ ---
114
+
115
+ ## MCP Tools (used by Claude automatically)
116
+
117
+ | Tool | What it does |
118
+ |---|---|
119
+ | `pg_search` | Semantic skill search by task description |
120
+ | `pg_list` | List all indexed skills |
121
+ | `pg_context` | Full skill details + callers/callees |
122
+ | `pg_callers` | Which skills reference this one |
123
+ | `pg_callees` | Which skills this one calls |
124
+ | `pg_impact` | What breaks if this skill changes |
125
+ | `pg_marketplace_browse` | Browse community registry |
126
+ | `pg_marketplace_install` | Install a skill by code, id, or name |
127
+ | `pg_bundle_browse` | Browse skill bundles |
128
+ | `pg_bundle_install` | Install a bundle |
129
+ | `pg_top_rated` | Highest-rated local skills |
130
+ | `pg_rate` | Rate a skill (success/fail) |
131
+
132
+ ---
133
+
134
+ ## Token Savings
135
+
136
+ | | Before PromptGraph | After PromptGraph |
137
+ |---|---|---|
138
+ | Skills in system prompt | All 40+ every session | 1 router (~150 tokens) |
139
+ | Tokens per session | 20,000 – 50,000 | ~300 + 1 skill on demand |
140
+ | Skills you can have | ~30 before it gets painful | Unlimited |
141
+
142
+ ---
143
+
144
+ ## How It Works
145
+
146
+ ```
147
+ pg_search("refactor without breaking tests")
148
+ embed query → HNSW ANN search → rank by cosine + rating boost
149
+ return top skill path
150
+ Claude reads only that file
151
+ ```
152
+
153
+ Embeddings are stored in SQLite. The HNSW index ([vectra](https://github.com/Stevenic/vectra)) keeps search sub-millisecond even at thousands of skills. Skills are re-indexed automatically via `chokidar` file watcher.
154
+
155
+ ---
156
+
157
+ ## Requirements
158
+
159
+ - Node.js 18+
160
+ - Claude Code or Claude Desktop (any MCP-compatible client)
161
+
162
+ ---
163
+
164
+ ## Related
165
+
166
+ - 📋 [promptgraph-registry](https://github.com/NeiP4n/promptgraph-registry) — community skill registry
167
+
168
+ ---
169
+
170
+ *Built with [Claude](https://claude.com/claude-code) by Anthropic.*
package/config.js CHANGED
@@ -42,7 +42,12 @@ export async function promptConfig() {
42
42
  if (extra.trim()) {
43
43
  const extraDirs = extra.split(',').map(d => d.trim()).filter(Boolean);
44
44
  for (const dir of extraDirs) {
45
- config.sources.push({ dir, source: 'custom' });
45
+ // Use basename so two different dirs named the same still get distinct ids
46
+ // if basename truly collides, append a short discriminator from the full path
47
+ const base = path.basename(path.resolve(dir));
48
+ const existing = config.sources.filter(s => s.source === `custom:${base}`);
49
+ const tag = existing.length === 0 ? `custom:${base}` : `custom:${base}-${existing.length}`;
50
+ config.sources.push({ dir, source: tag });
46
51
  }
47
52
  }
48
53
 
package/github-import.js CHANGED
@@ -1,4 +1,4 @@
1
- import { execSync } from 'child_process';
1
+ import { spawnSync } from 'child_process';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
4
  import fs from 'fs';
@@ -22,13 +22,12 @@ export async function importFromGitHub(repoUrl) {
22
22
 
23
23
  if (fs.existsSync(dest)) {
24
24
  console.log(`Updating ${repoName}...`);
25
- execSync('git', { stdio: 'inherit', args: ['-C', dest, 'pull', '--depth=1'] });
25
+ const pullResult = spawnSync('git', ['-C', dest, 'pull', '--depth=1'], { stdio: 'inherit' });
26
+ if (pullResult.status !== 0) throw new Error(`git pull failed for ${repoName}`);
26
27
  } else {
27
28
  console.log(`Cloning ${url}...`);
28
- // use spawnSync to avoid shell injection
29
- const { spawnSync } = await import('child_process');
30
- const result = spawnSync('git', ['clone', '--depth=1', url, dest], { stdio: 'inherit' });
31
- if (result.status !== 0) throw new Error(`git clone failed for ${url}`);
29
+ const cloneResult = spawnSync('git', ['clone', '--depth=1', url, dest], { stdio: 'inherit' });
30
+ if (cloneResult.status !== 0) throw new Error(`git clone failed for ${url}`);
32
31
  }
33
32
 
34
33
  const mdFiles = globSync(`${dest}/**/*.md`);
@@ -39,11 +38,12 @@ export async function importFromGitHub(repoUrl) {
39
38
  }
40
39
 
41
40
  const config = loadConfig();
42
- const githubDir = path.join(SKILLS_DIR, 'github');
43
- if (!config.sources.find(s => s.dir === githubDir)) {
44
- config.sources.push({ dir: githubDir, source: 'github' });
41
+ // Per-repo source so two repos with the same skill name don't overwrite each other
42
+ const repoSource = `github:${repoName}`;
43
+ if (!config.sources.find(s => s.dir === dest)) {
44
+ config.sources.push({ dir: dest, source: repoSource });
45
45
  saveConfig(config);
46
- console.log('Added github dir to config');
46
+ console.log(`Added ${repoSource} to config`);
47
47
  }
48
48
 
49
49
  console.log('\nReindexing...');
package/indexer.js CHANGED
@@ -77,11 +77,24 @@ export async function indexAll() {
77
77
  const config = loadConfig();
78
78
  const db = getDb();
79
79
 
80
- // collect all files on disk
80
+ // collect all files on disk — use longest-matching source for files in subdirs
81
+ // (e.g. skills-store/marketplace/*.md → 'marketplace', not 'skills-store')
82
+ const normalizedSources = config.sources.map(s => ({
83
+ ...s,
84
+ normDir: path.resolve(s.dir),
85
+ })).sort((a, b) => b.normDir.length - a.normDir.length); // longest first
86
+
87
+ const seenFiles = new Set();
81
88
  const allFiles = [];
82
- for (const { dir, source } of config.sources) {
89
+ for (const { dir, source } of normalizedSources) {
83
90
  const files = globSync(`${dir}/**/*.md`);
84
- files.forEach(f => allFiles.push({ file: f, source }));
91
+ for (const f of files) {
92
+ const norm = path.resolve(f);
93
+ if (!seenFiles.has(norm)) {
94
+ seenFiles.add(norm);
95
+ allFiles.push({ file: f, source });
96
+ }
97
+ }
85
98
  }
86
99
  const total = allFiles.length;
87
100
  info(`Found ${chalk.white.bold(total)} files`);
@@ -149,6 +162,16 @@ export async function indexAll() {
149
162
  }
150
163
  } catch {
151
164
  errors++;
165
+ // Remove stale record if the file was previously indexed but now fails to parse
166
+ try {
167
+ const stale = db.prepare('SELECT id FROM skills WHERE path = ?').get(file);
168
+ if (stale) {
169
+ db.prepare('DELETE FROM skills WHERE id = ?').run(stale.id);
170
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(stale.id);
171
+ db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(stale.id, stale.id);
172
+ db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(stale.id);
173
+ }
174
+ } catch {}
152
175
  }
153
176
  }
154
177
 
package/marketplace.js CHANGED
@@ -6,6 +6,7 @@ import { createHash } from 'crypto';
6
6
  import { spawnSync } from 'child_process';
7
7
  import { getDb } from './db.js';
8
8
  import { validateSkill } from './validator.js';
9
+ import { loadConfig, saveConfig } from './config.js';
9
10
 
10
11
  const REGISTRY_URL = 'https://raw.githubusercontent.com/NeiP4n/promptgraph-registry/main/registry.json';
11
12
  const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills-store', 'marketplace');
@@ -112,10 +113,20 @@ export async function installSkill(query) {
112
113
  const skillId = skill.id;
113
114
 
114
115
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
116
+ ensureMarketplaceSource();
115
117
  const dest = path.join(SKILLS_DIR, `${skillId}.md`);
116
118
 
117
119
  const content = await fetchText(skill.raw_url);
118
- fs.writeFileSync(dest, content);
120
+
121
+ // Validate before writing — reject malicious or junk downloads
122
+ const tmpPath = dest + '.tmp';
123
+ fs.writeFileSync(tmpPath, content);
124
+ const validation = validateSkill(tmpPath);
125
+ if (!validation.ok) {
126
+ fs.unlinkSync(tmpPath);
127
+ return { error: 'Downloaded skill failed validation', issues: validation.errors };
128
+ }
129
+ fs.renameSync(tmpPath, dest);
119
130
 
120
131
  return { success: true, path: dest, name: skill.name };
121
132
  } catch (e) {
@@ -137,6 +148,16 @@ export async function browseBundles(topK = 20) {
137
148
  }
138
149
  }
139
150
 
151
+ // Ensure marketplace has its own source entry (separate from skills-store)
152
+ // so marketplace skills never collide with local skills of the same name.
153
+ function ensureMarketplaceSource() {
154
+ const config = loadConfig();
155
+ if (!config.sources.find(s => s.source === 'marketplace')) {
156
+ config.sources.push({ dir: SKILLS_DIR, source: 'marketplace' });
157
+ saveConfig(config);
158
+ }
159
+ }
160
+
140
161
  export async function installBundle(bundleId) {
141
162
  try {
142
163
  const text = await fetchText(REGISTRY_URL);
@@ -148,6 +169,7 @@ export async function installBundle(bundleId) {
148
169
  if (!bundle) return { error: `No bundle matching "${bundleId}"` };
149
170
 
150
171
  fs.mkdirSync(SKILLS_DIR, { recursive: true });
172
+ ensureMarketplaceSource();
151
173
  const installed = [];
152
174
  const failed = [];
153
175
 
@@ -156,7 +178,12 @@ export async function installBundle(bundleId) {
156
178
  if (!skill?.raw_url) { failed.push(skillId); continue; }
157
179
  try {
158
180
  const content = await fetchText(skill.raw_url);
159
- fs.writeFileSync(path.join(SKILLS_DIR, `${skillId}.md`), content);
181
+ const dest = path.join(SKILLS_DIR, `${skillId}.md`);
182
+ const tmpPath = dest + '.tmp';
183
+ fs.writeFileSync(tmpPath, content);
184
+ const validation = validateSkill(tmpPath);
185
+ if (!validation.ok) { fs.unlinkSync(tmpPath); failed.push(skillId); continue; }
186
+ fs.renameSync(tmpPath, dest);
160
187
  installed.push(skillId);
161
188
  } catch {
162
189
  failed.push(skillId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "promptgraph-mcp",
3
- "version": "1.5.21",
3
+ "version": "1.5.23",
4
4
  "main": "index.js",
5
5
  "type": "module",
6
6
  "bin": {
package/search.js CHANGED
@@ -54,44 +54,53 @@ export async function search(query, topK = 5) {
54
54
  .filter(Boolean);
55
55
  }
56
56
 
57
- export function getContext(id) {
57
+ export function getContext(nameOrId) {
58
58
  const db = getDb();
59
- const skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(id)
60
- || db.prepare('SELECT * FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(id);
59
+ let skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(nameOrId);
60
+ if (!skill) {
61
+ const res = resolveId(db, nameOrId);
62
+ if (res.error) return res;
63
+ skill = db.prepare('SELECT * FROM skills WHERE id = ?').get(res.id);
64
+ }
61
65
  if (!skill) return null;
62
66
  const callees = db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(skill.id).map(r => r.to_skill);
63
67
  const callers = db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(skill.id).map(r => r.from_skill);
68
+
64
69
  return { ...skill, callees, callers };
65
70
  }
66
71
 
67
72
  function resolveId(db, nameOrId) {
68
73
  const byId = db.prepare('SELECT id FROM skills WHERE id = ?').get(nameOrId);
69
- if (byId) return byId.id;
70
- const byName = db.prepare('SELECT id FROM skills WHERE name = ? ORDER BY id LIMIT 1').get(nameOrId);
71
- if (byName) return byName.id;
72
- return null;
74
+ if (byId) return { id: byId.id };
75
+ const byName = db.prepare('SELECT id FROM skills WHERE name = ?').all(nameOrId);
76
+ if (byName.length === 1) return { id: byName[0].id };
77
+ if (byName.length > 1) {
78
+ const candidates = byName.map(r => r.id).join(', ');
79
+ return { error: `Ambiguous name "${nameOrId}" — multiple skills match. Use a full id: ${candidates}` };
80
+ }
81
+ return { error: `Skill not found: ${nameOrId}` };
73
82
  }
74
83
 
75
84
  export function getCallers(nameOrId) {
76
85
  const db = getDb();
77
- const id = resolveId(db, nameOrId);
78
- if (!id) return { error: `Skill not found: ${nameOrId}` };
79
- return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(id).map(r => r.from_skill);
86
+ const res = resolveId(db, nameOrId);
87
+ if (res.error) return res;
88
+ return db.prepare('SELECT from_skill FROM edges WHERE to_skill = ?').all(res.id).map(r => r.from_skill);
80
89
  }
81
90
 
82
91
  export function getCallees(nameOrId) {
83
92
  const db = getDb();
84
- const id = resolveId(db, nameOrId);
85
- if (!id) return { error: `Skill not found: ${nameOrId}` };
86
- return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(id).map(r => r.to_skill);
93
+ const res = resolveId(db, nameOrId);
94
+ if (res.error) return res;
95
+ return db.prepare('SELECT to_skill FROM edges WHERE from_skill = ?').all(res.id).map(r => r.to_skill);
87
96
  }
88
97
 
89
98
  export function getImpact(nameOrId) {
90
99
  const db = getDb();
91
- const id = resolveId(db, nameOrId);
92
- if (!id) return { error: `Skill not found: ${nameOrId}` };
100
+ const res = resolveId(db, nameOrId);
101
+ if (res.error) return res;
93
102
  const visited = new Set();
94
- const queue = [id];
103
+ const queue = [res.id];
95
104
  while (queue.length) {
96
105
  const cur = queue.shift();
97
106
  if (visited.has(cur)) continue;