promptgraph-mcp 2.8.1 → 2.8.3

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/api.js CHANGED
@@ -1,202 +1,202 @@
1
- import { search as internalSearch, getContext, listAll as internalList } from './search.js';
2
- import { indexAll, indexSource } from './indexer.js';
3
- import { getDb } from './db.js';
4
- import { loadConfig } from './config.js';
5
-
6
- /**
7
- * Search for skills by query text.
8
- * @param {string} query - Natural language search query
9
- * @param {{ topK?: number, embedWeight?: number, bm25Weight?: number, source?: string, category?: string }} [options]
10
- * @returns {Promise<Array<{id: string, name: string, description: string, source: string, score: number, snippet: string}>>}
11
- */
12
- export async function search(query, options = {}) {
13
- try {
14
- const { topK = 5, source } = options;
15
- const results = await internalSearch(query, source ? topK * 3 : topK);
16
- if (source) {
17
- return results.filter(r => r.source === source).slice(0, topK);
18
- }
19
- return results;
20
- } catch {
21
- return [];
22
- }
23
- }
24
-
25
- /**
26
- * Index all .md files in a directory under a source name.
27
- * @param {string} sourceDir - Path to directory containing .md skill files
28
- * @param {string} sourceName - Source label stored with each skill
29
- * @returns {Promise<{indexed: number, skipped: number, errors: number}>}
30
- */
31
- export async function index(sourceDir, sourceName) {
32
- try {
33
- if (sourceDir.includes('..')) {
34
- return { indexed: 0, skipped: 0, errors: 1, error: 'Path traversal detected in sourceDir' };
35
- }
36
- const db = getDb();
37
- const { globSync } = await import('glob');
38
- const fs = await import('fs');
39
- const path = await import('path');
40
- const { createHash } = await import('crypto');
41
- const { parseSkillFile, isSkillFile } = await import('./parser.js');
42
- const { embedBatch } = await import('./embedder.js');
43
- const { BATCH_SIZE } = await import('./config.js');
44
- const { skillId, vecToBlob } = await import('./db.js');
45
- const { chunkText } = await import('./chunker.js');
46
-
47
- const files = globSync(`${sourceDir}/**/*.md`);
48
- let indexed = 0, skipped = 0, errors = 0;
49
- const batch = [];
50
-
51
- for (const file of files) {
52
- try {
53
- const stat = fs.statSync(file);
54
- if (stat.size > 5 * 1024 * 1024) { skipped++; continue; }
55
- const raw = fs.readFileSync(file, 'utf8');
56
- const hash = createHash('md5').update(raw).digest('hex');
57
- if (!isSkillFile(file, raw)) { skipped++; continue; }
58
- const parsed = parseSkillFile(file, sourceName, { raw });
59
- batch.push({ ...parsed, hash });
60
- if (batch.length >= BATCH_SIZE) {
61
- await indexBatch(db, batch);
62
- indexed += batch.length;
63
- batch.length = 0;
64
- }
65
- } catch { errors++; }
66
- }
67
- if (batch.length > 0) {
68
- await indexBatch(db, batch);
69
- indexed += batch.length;
70
- }
71
- return { indexed, skipped, errors };
72
- } catch (e) {
73
- return { indexed: 0, skipped: 0, errors: 1, error: e.message };
74
- }
75
- }
76
-
77
- /**
78
- * Remove a skill by its ID.
79
- * @param {string} skillId - Skill ID (format: "source::name")
80
- * @returns {{ok: boolean, error?: string}}
81
- */
82
- export function remove(skillId) {
83
- try {
84
- const db = getDb();
85
- db.prepare('DELETE FROM skills WHERE id = ?').run(skillId);
86
- db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(skillId);
87
- db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(skillId, skillId);
88
- db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(skillId);
89
- return { ok: true };
90
- } catch (e) {
91
- return { ok: false, error: e.message };
92
- }
93
- }
94
-
95
- /**
96
- * Reindex changed files only (hash-based). Scans all configured sources,
97
- * updates files whose content hash changed, and removes stale skills.
98
- * @returns {Promise<{updated: number, removed: number, errors: number}>}
99
- */
100
- export async function update() {
101
- try {
102
- const db = getDb();
103
- const config = loadConfig();
104
- const { globSync } = await import('glob');
105
- const fs = await import('fs');
106
- const path = await import('path');
107
- const { createHash } = await import('crypto');
108
- const { parseSkillFile, isSkillFile } = await import('./parser.js');
109
- const { embedBatch } = await import('./embedder.js');
110
- const { BATCH_SIZE } = await import('./config.js');
111
- const { skillId, vecToBlob } = await import('./db.js');
112
- const { chunkText } = await import('./chunker.js');
113
- const { indexBatch } = await import('./indexer.js');
114
-
115
- const normalizedSources = config.sources.map(s => ({ ...s, normDir: path.resolve(s.dir) }))
116
- .sort((a, b) => b.normDir.length - a.normDir.length);
117
-
118
- const seenFiles = new Set();
119
- const allFiles = [];
120
- for (const { dir, source } of normalizedSources) {
121
- const files = globSync(`${dir}/**/*.md`);
122
- for (const f of files) {
123
- const norm = path.resolve(f);
124
- if (!seenFiles.has(norm)) { seenFiles.add(norm); allFiles.push({ file: norm, source }); }
125
- }
126
- }
127
-
128
- const dbByPath = new Map();
129
- for (const row of db.prepare('SELECT id, path, hash FROM skills').all()) {
130
- dbByPath.set(row.path, row);
131
- }
132
-
133
- const existingPaths = new Set(allFiles.map(f => f.file));
134
- let removed = 0;
135
- for (const [filePath, row] of dbByPath) {
136
- if (!existingPaths.has(filePath)) {
137
- db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
138
- db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
139
- db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
140
- db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(row.id);
141
- removed++;
142
- }
143
- }
144
-
145
- let updated = 0, errors = 0, batch = [];
146
- for (const { file, source } of allFiles) {
147
- try {
148
- const raw = fs.readFileSync(file, 'utf8');
149
- const hash = createHash('md5').update(raw).digest('hex');
150
- const dbRow = dbByPath.get(file);
151
- if (dbRow?.hash === hash) continue;
152
- if (!isSkillFile(file, raw)) continue;
153
- const parsed = parseSkillFile(file, source, { raw });
154
- batch.push({ ...parsed, hash });
155
- if (batch.length >= BATCH_SIZE) {
156
- await indexBatch(db, batch);
157
- updated += batch.length;
158
- batch.length = 0;
159
- }
160
- } catch { errors++; }
161
- }
162
- if (batch.length > 0) {
163
- await indexBatch(db, batch);
164
- updated += batch.length;
165
- }
166
-
167
- return { updated, removed, errors };
168
- } catch (e) {
169
- return { updated: 0, removed: 0, errors: 1, error: e.message };
170
- }
171
- }
172
-
173
- /**
174
- * Get full skill details by ID, including edges.
175
- * @param {string} skillId
176
- * @returns {object|null}
177
- */
178
- export function get(skillId) {
179
- try {
180
- return getContext(skillId);
181
- } catch {
182
- return null;
183
- }
184
- }
185
-
186
- /**
187
- * List all skills with optional filtering.
188
- * @param {{ source?: string, category?: string, limit?: number, offset?: number }} [options]
189
- * @returns {Array<object>}
190
- */
191
- export function list(options = {}) {
192
- try {
193
- const { source, limit, offset } = options;
194
- let results = internalList();
195
- if (source) results = results.filter(r => r.source === source);
196
- if (offset) results = results.slice(offset);
197
- if (limit) results = results.slice(0, limit);
198
- return results;
199
- } catch {
200
- return [];
201
- }
202
- }
1
+ import { search as internalSearch, getContext, listAll as internalList } from './search.js';
2
+ import { indexAll, indexSource } from './indexer.js';
3
+ import { getDb } from './db.js';
4
+ import { loadConfig } from './config.js';
5
+
6
+ /**
7
+ * Search for skills by query text.
8
+ * @param {string} query - Natural language search query
9
+ * @param {{ topK?: number, embedWeight?: number, bm25Weight?: number, source?: string, category?: string }} [options]
10
+ * @returns {Promise<Array<{id: string, name: string, description: string, source: string, score: number, snippet: string}>>}
11
+ */
12
+ export async function search(query, options = {}) {
13
+ try {
14
+ const { topK = 5, source } = options;
15
+ const results = await internalSearch(query, source ? topK * 3 : topK);
16
+ if (source) {
17
+ return results.filter(r => r.source === source).slice(0, topK);
18
+ }
19
+ return results;
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Index all .md files in a directory under a source name.
27
+ * @param {string} sourceDir - Path to directory containing .md skill files
28
+ * @param {string} sourceName - Source label stored with each skill
29
+ * @returns {Promise<{indexed: number, skipped: number, errors: number}>}
30
+ */
31
+ export async function index(sourceDir, sourceName) {
32
+ try {
33
+ if (sourceDir.includes('..')) {
34
+ return { indexed: 0, skipped: 0, errors: 1, error: 'Path traversal detected in sourceDir' };
35
+ }
36
+ const db = getDb();
37
+ const { globSync } = await import('glob');
38
+ const fs = await import('fs');
39
+ const path = await import('path');
40
+ const { createHash } = await import('crypto');
41
+ const { parseSkillFile, isSkillFile } = await import('./parser.js');
42
+ const { embedBatch } = await import('./embedder.js');
43
+ const { BATCH_SIZE } = await import('./config.js');
44
+ const { skillId, vecToBlob } = await import('./db.js');
45
+ const { chunkText } = await import('./chunker.js');
46
+
47
+ const files = globSync(`${sourceDir}/**/*.md`);
48
+ let indexed = 0, skipped = 0, errors = 0;
49
+ const batch = [];
50
+
51
+ for (const file of files) {
52
+ try {
53
+ const stat = fs.statSync(file);
54
+ if (stat.size > 5 * 1024 * 1024) { skipped++; continue; }
55
+ const raw = fs.readFileSync(file, 'utf8');
56
+ const hash = createHash('md5').update(raw).digest('hex');
57
+ if (!isSkillFile(file, raw)) { skipped++; continue; }
58
+ const parsed = parseSkillFile(file, sourceName, { raw });
59
+ batch.push({ ...parsed, hash });
60
+ if (batch.length >= BATCH_SIZE) {
61
+ await indexBatch(db, batch);
62
+ indexed += batch.length;
63
+ batch.length = 0;
64
+ }
65
+ } catch { errors++; }
66
+ }
67
+ if (batch.length > 0) {
68
+ await indexBatch(db, batch);
69
+ indexed += batch.length;
70
+ }
71
+ return { indexed, skipped, errors };
72
+ } catch (e) {
73
+ return { indexed: 0, skipped: 0, errors: 1, error: e.message };
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Remove a skill by its ID.
79
+ * @param {string} skillId - Skill ID (format: "source::name")
80
+ * @returns {{ok: boolean, error?: string}}
81
+ */
82
+ export function remove(skillId) {
83
+ try {
84
+ const db = getDb();
85
+ db.prepare('DELETE FROM skills WHERE id = ?').run(skillId);
86
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(skillId);
87
+ db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(skillId, skillId);
88
+ db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(skillId);
89
+ return { ok: true };
90
+ } catch (e) {
91
+ return { ok: false, error: e.message };
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Reindex changed files only (hash-based). Scans all configured sources,
97
+ * updates files whose content hash changed, and removes stale skills.
98
+ * @returns {Promise<{updated: number, removed: number, errors: number}>}
99
+ */
100
+ export async function update() {
101
+ try {
102
+ const db = getDb();
103
+ const config = loadConfig();
104
+ const { globSync } = await import('glob');
105
+ const fs = await import('fs');
106
+ const path = await import('path');
107
+ const { createHash } = await import('crypto');
108
+ const { parseSkillFile, isSkillFile } = await import('./parser.js');
109
+ const { embedBatch } = await import('./embedder.js');
110
+ const { BATCH_SIZE } = await import('./config.js');
111
+ const { skillId, vecToBlob } = await import('./db.js');
112
+ const { chunkText } = await import('./chunker.js');
113
+ const { indexBatch } = await import('./indexer.js');
114
+
115
+ const normalizedSources = config.sources.map(s => ({ ...s, normDir: path.resolve(s.dir) }))
116
+ .sort((a, b) => b.normDir.length - a.normDir.length);
117
+
118
+ const seenFiles = new Set();
119
+ const allFiles = [];
120
+ for (const { dir, source } of normalizedSources) {
121
+ const files = globSync(`${dir}/**/*.md`);
122
+ for (const f of files) {
123
+ const norm = path.resolve(f);
124
+ if (!seenFiles.has(norm)) { seenFiles.add(norm); allFiles.push({ file: norm, source }); }
125
+ }
126
+ }
127
+
128
+ const dbByPath = new Map();
129
+ for (const row of db.prepare('SELECT id, path, hash FROM skills').all()) {
130
+ dbByPath.set(row.path, row);
131
+ }
132
+
133
+ const existingPaths = new Set(allFiles.map(f => f.file));
134
+ let removed = 0;
135
+ for (const [filePath, row] of dbByPath) {
136
+ if (!existingPaths.has(filePath)) {
137
+ db.prepare('DELETE FROM skills WHERE id = ?').run(row.id);
138
+ db.prepare('DELETE FROM chunks WHERE skill_id = ?').run(row.id);
139
+ db.prepare('DELETE FROM edges WHERE from_skill = ? OR to_skill = ?').run(row.id, row.id);
140
+ db.prepare('DELETE FROM ratings WHERE skill_id = ?').run(row.id);
141
+ removed++;
142
+ }
143
+ }
144
+
145
+ let updated = 0, errors = 0, batch = [];
146
+ for (const { file, source } of allFiles) {
147
+ try {
148
+ const raw = fs.readFileSync(file, 'utf8');
149
+ const hash = createHash('md5').update(raw).digest('hex');
150
+ const dbRow = dbByPath.get(file);
151
+ if (dbRow?.hash === hash) continue;
152
+ if (!isSkillFile(file, raw)) continue;
153
+ const parsed = parseSkillFile(file, source, { raw });
154
+ batch.push({ ...parsed, hash });
155
+ if (batch.length >= BATCH_SIZE) {
156
+ await indexBatch(db, batch);
157
+ updated += batch.length;
158
+ batch.length = 0;
159
+ }
160
+ } catch { errors++; }
161
+ }
162
+ if (batch.length > 0) {
163
+ await indexBatch(db, batch);
164
+ updated += batch.length;
165
+ }
166
+
167
+ return { updated, removed, errors };
168
+ } catch (e) {
169
+ return { updated: 0, removed: 0, errors: 1, error: e.message };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Get full skill details by ID, including edges.
175
+ * @param {string} skillId
176
+ * @returns {object|null}
177
+ */
178
+ export function get(skillId) {
179
+ try {
180
+ return getContext(skillId);
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * List all skills with optional filtering.
188
+ * @param {{ source?: string, category?: string, limit?: number, offset?: number }} [options]
189
+ * @returns {Array<object>}
190
+ */
191
+ export function list(options = {}) {
192
+ try {
193
+ const { source, limit, offset } = options;
194
+ let results = internalList();
195
+ if (source) results = results.filter(r => r.source === source);
196
+ if (offset) results = results.slice(offset);
197
+ if (limit) results = results.slice(0, limit);
198
+ return results;
199
+ } catch {
200
+ return [];
201
+ }
202
+ }
package/bundle-counts.js CHANGED
@@ -1,111 +1,111 @@
1
- /**
2
- * Local cache for bundle skillCounts.
3
- * Refreshes from GitHub API in background; TTL = 24h.
4
- * Counts only .md files in subdirectories (never root files).
5
- */
6
- import fs from 'fs';
7
- import https from 'https';
8
- import path from 'path';
9
- import { PROMPTGRAPH_DIR } from './config.js';
10
-
11
- const CACHE_FILE = path.join(PROMPTGRAPH_DIR, 'bundle-counts.json');
12
- const TTL_MS = 24 * 60 * 60 * 1000; // 24h
13
-
14
- const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
15
- const SKIP_ROOT_DIRS = new Set(['.github', 'docs', 'doc', 'assets', 'images', 'img', 'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor', 'dist', 'build', 'tests', 'test', 'examples', 'example', 'fixtures']);
16
- const SKIP_NAMES = /^(readme|changelog|license|contributing|security|authors|credits|install|installation|usage|promotion|faq|glossary|index|overview|summary|roadmap|todo|notes|template|example|sample|demo|guide|tutorial|walkthrough|architecture|design|spec|requirements|privacy|terms|disclaimer|notice|copying|warranty|funding)/i;
17
-
18
- function httpsGet(url) {
19
- const token = process.env.GITHUB_TOKEN;
20
- const headers = { 'User-Agent': 'promptgraph-mcp' };
21
- if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
22
- return new Promise((res, rej) => {
23
- const req = https.get(url, { headers, timeout: 15000 }, r => {
24
- if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
25
- if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
26
- const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
27
- });
28
- req.on('error', rej);
29
- req.on('timeout', () => { req.destroy(new Error('timeout')); });
30
- });
31
- }
32
-
33
- function loadCache() {
34
- try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
35
- }
36
-
37
- function saveCache(data) {
38
- try {
39
- fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
40
- fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
41
- } catch {}
42
- }
43
-
44
- // Count .md files only in subdirectories — root files are ignored
45
- async function countSubdirMdFiles(ownerRepo) {
46
- const treeUrl = `https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`;
47
- const json = await httpsGet(treeUrl);
48
- const { tree = [] } = JSON.parse(json);
49
-
50
- // Only .md files that are inside a subdir (path has at least one /)
51
- const mdFiles = tree.filter(f =>
52
- f.type === 'blob' &&
53
- f.path.endsWith('.md') &&
54
- f.path.includes('/') // must be in a subdir, not root
55
- );
56
-
57
- // Try to find a known skills subdir first
58
- for (const dir of SKILL_DIRS) {
59
- const inDir = mdFiles.filter(f => f.path.startsWith(dir + '/'));
60
- if (inDir.length > 0) {
61
- return inDir.filter(f => {
62
- const base = f.path.split('/').pop().replace(/\.md$/i, '').toLowerCase();
63
- return !SKIP_NAMES.test(base);
64
- }).length;
65
- }
66
- }
67
-
68
- // No known dir — use any subdir, skip skip-dirs
69
- return mdFiles.filter(f => {
70
- const parts = f.path.split('/');
71
- const topDir = parts[0].toLowerCase();
72
- if (SKIP_ROOT_DIRS.has(topDir)) return false;
73
- const base = parts[parts.length - 1].replace(/\.md$/i, '').toLowerCase();
74
- return !SKIP_NAMES.test(base);
75
- }).length;
76
- }
77
-
78
- // Read cached count for a bundle (returns null if stale or missing)
79
- export function getCachedCount(repoUrl) {
80
- const cache = loadCache();
81
- const entry = cache[repoUrl];
82
- if (!entry) return null;
83
- if (Date.now() - entry.ts > TTL_MS) return null;
84
- return entry.count;
85
- }
86
-
87
- // Refresh counts for all bundles in background (non-blocking)
88
- export function refreshCountsInBackground(bundles) {
89
- const cache = loadCache();
90
- const stale = bundles.filter(b => {
91
- if (!b.repo_url) return false;
92
- const entry = cache[b.repo_url];
93
- return !entry || Date.now() - entry.ts > TTL_MS;
94
- });
95
-
96
- if (!stale.length) return;
97
-
98
- // Fire-and-forget
99
- (async () => {
100
- for (const b of stale) {
101
- try {
102
- const count = await countSubdirMdFiles(b.repo_url);
103
- cache[b.repo_url] = { count, ts: Date.now() };
104
- saveCache(cache);
105
- } catch {
106
- // rate limited or network error — skip, try next time
107
- }
108
- await new Promise(r => setTimeout(r, 500)); // 500ms between requests
109
- }
110
- })();
111
- }
1
+ /**
2
+ * Local cache for bundle skillCounts.
3
+ * Refreshes from GitHub API in background; TTL = 24h.
4
+ * Counts only .md files in subdirectories (never root files).
5
+ */
6
+ import fs from 'fs';
7
+ import https from 'https';
8
+ import path from 'path';
9
+ import { PROMPTGRAPH_DIR } from './config.js';
10
+
11
+ const CACHE_FILE = path.join(PROMPTGRAPH_DIR, 'bundle-counts.json');
12
+ const TTL_MS = 24 * 60 * 60 * 1000; // 24h
13
+
14
+ const SKILL_DIRS = ['skills', 'commands', 'prompts', 'agents', 'skills-store', 'slash-commands', 'custom-commands', 'templates'];
15
+ const SKIP_ROOT_DIRS = new Set(['.github', 'docs', 'doc', 'assets', 'images', 'img', 'media', 'static', 'scripts', 'ci_scripts', 'node_modules', 'vendor', 'dist', 'build', 'tests', 'test', 'examples', 'example', 'fixtures']);
16
+ const SKIP_NAMES = /^(readme|changelog|license|contributing|security|authors|credits|install|installation|usage|promotion|faq|glossary|index|overview|summary|roadmap|todo|notes|template|example|sample|demo|guide|tutorial|walkthrough|architecture|design|spec|requirements|privacy|terms|disclaimer|notice|copying|warranty|funding)/i;
17
+
18
+ function httpsGet(url) {
19
+ const token = process.env.GITHUB_TOKEN;
20
+ const headers = { 'User-Agent': 'promptgraph-mcp' };
21
+ if (token && url.startsWith('https://api.github.com/')) headers['Authorization'] = `Bearer ${token}`;
22
+ return new Promise((res, rej) => {
23
+ const req = https.get(url, { headers, timeout: 15000 }, r => {
24
+ if (r.statusCode === 403 || r.statusCode === 429) return rej(new Error(`Rate limited`));
25
+ if (r.statusCode !== 200) { r.resume(); return rej(new Error(`HTTP ${r.statusCode}`)); }
26
+ const chunks = []; r.setEncoding('utf8'); r.on('data', c => chunks.push(c)); r.on('end', () => res(chunks.join('')));
27
+ });
28
+ req.on('error', rej);
29
+ req.on('timeout', () => { req.destroy(new Error('timeout')); });
30
+ });
31
+ }
32
+
33
+ function loadCache() {
34
+ try { return JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8')); } catch { return {}; }
35
+ }
36
+
37
+ function saveCache(data) {
38
+ try {
39
+ fs.mkdirSync(path.dirname(CACHE_FILE), { recursive: true });
40
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2));
41
+ } catch {}
42
+ }
43
+
44
+ // Count .md files only in subdirectories — root files are ignored
45
+ async function countSubdirMdFiles(ownerRepo) {
46
+ const treeUrl = `https://api.github.com/repos/${ownerRepo}/git/trees/HEAD?recursive=1`;
47
+ const json = await httpsGet(treeUrl);
48
+ const { tree = [] } = JSON.parse(json);
49
+
50
+ // Only .md files that are inside a subdir (path has at least one /)
51
+ const mdFiles = tree.filter(f =>
52
+ f.type === 'blob' &&
53
+ f.path.endsWith('.md') &&
54
+ f.path.includes('/') // must be in a subdir, not root
55
+ );
56
+
57
+ // Try to find a known skills subdir first
58
+ for (const dir of SKILL_DIRS) {
59
+ const inDir = mdFiles.filter(f => f.path.startsWith(dir + '/'));
60
+ if (inDir.length > 0) {
61
+ return inDir.filter(f => {
62
+ const base = f.path.split('/').pop().replace(/\.md$/i, '').toLowerCase();
63
+ return !SKIP_NAMES.test(base);
64
+ }).length;
65
+ }
66
+ }
67
+
68
+ // No known dir — use any subdir, skip skip-dirs
69
+ return mdFiles.filter(f => {
70
+ const parts = f.path.split('/');
71
+ const topDir = parts[0].toLowerCase();
72
+ if (SKIP_ROOT_DIRS.has(topDir)) return false;
73
+ const base = parts[parts.length - 1].replace(/\.md$/i, '').toLowerCase();
74
+ return !SKIP_NAMES.test(base);
75
+ }).length;
76
+ }
77
+
78
+ // Read cached count for a bundle (returns null if stale or missing)
79
+ export function getCachedCount(repoUrl) {
80
+ const cache = loadCache();
81
+ const entry = cache[repoUrl];
82
+ if (!entry) return null;
83
+ if (Date.now() - entry.ts > TTL_MS) return null;
84
+ return entry.count;
85
+ }
86
+
87
+ // Refresh counts for all bundles in background (non-blocking)
88
+ export function refreshCountsInBackground(bundles) {
89
+ const cache = loadCache();
90
+ const stale = bundles.filter(b => {
91
+ if (!b.repo_url) return false;
92
+ const entry = cache[b.repo_url];
93
+ return !entry || Date.now() - entry.ts > TTL_MS;
94
+ });
95
+
96
+ if (!stale.length) return;
97
+
98
+ // Fire-and-forget
99
+ (async () => {
100
+ for (const b of stale) {
101
+ try {
102
+ const count = await countSubdirMdFiles(b.repo_url);
103
+ cache[b.repo_url] = { count, ts: Date.now() };
104
+ saveCache(cache);
105
+ } catch {
106
+ // rate limited or network error — skip, try next time
107
+ }
108
+ await new Promise(r => setTimeout(r, 500)); // 500ms between requests
109
+ }
110
+ })();
111
+ }