@seojin08/skill-finder 0.0.0-stage → 0.3.2
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/.env.example +3 -0
- package/CHANGELOG.md +39 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/bin/skill-finder.mjs +389 -0
- package/package.json +39 -4
- package/server/src/analyze/frontmatter.js +130 -0
- package/server/src/analyze/quality.js +34 -0
- package/server/src/analyze/risk.js +123 -0
- package/server/src/app.js +57 -0
- package/server/src/bootstrap.js +46 -0
- package/server/src/claude/cli.js +89 -0
- package/server/src/config.js +70 -0
- package/server/src/context.js +1 -0
- package/server/src/db/index.js +128 -0
- package/server/src/db/schema.js +95 -0
- package/server/src/db/store.js +353 -0
- package/server/src/discovery/classifyRepo.js +81 -0
- package/server/src/discovery/genericRules.js +180 -0
- package/server/src/discovery/pluginRules.js +59 -0
- package/server/src/discovery/tree.js +79 -0
- package/server/src/index.js +55 -0
- package/server/src/indexer.js +502 -0
- package/server/src/install/installed.js +206 -0
- package/server/src/install/plan.js +118 -0
- package/server/src/install/run.js +97 -0
- package/server/src/install/scan.js +70 -0
- package/server/src/install/summary.js +14 -0
- package/server/src/install/uninstall.js +59 -0
- package/server/src/install/validate.js +24 -0
- package/server/src/jobs.js +107 -0
- package/server/src/recommend/readme.js +508 -0
- package/server/src/release.js +41 -0
- package/server/src/routes/admin.js +203 -0
- package/server/src/routes/favorites.js +61 -0
- package/server/src/routes/health.js +27 -0
- package/server/src/routes/install.js +283 -0
- package/server/src/routes/presets.js +66 -0
- package/server/src/routes/recommend.js +36 -0
- package/server/src/routes/search.js +53 -0
- package/server/src/routes/skills.js +214 -0
- package/server/src/search/activity.js +13 -0
- package/server/src/search/category.js +109 -0
- package/server/src/search/koreanTerms.js +284 -0
- package/server/src/search/pipeline.js +975 -0
- package/server/src/search/rank.js +138 -0
- package/server/src/search/result.js +129 -0
- package/server/src/search/searchLog.js +53 -0
- package/server/src/security.js +53 -0
- package/server/src/settings.js +56 -0
- package/server/src/sources/gitTree.js +101 -0
- package/server/src/sources/github.js +503 -0
- package/server/src/sources/marketplace.js +78 -0
- package/server/src/sources/pluginSource.js +98 -0
- package/server/src/sources/popularRepos.js +74 -0
- package/server/src/sources/skillsSh.js +74 -0
- package/server/src/tasks.js +216 -0
- package/server/src/update.js +53 -0
- package/server/src/util/folderPicker.js +208 -0
- package/server/src/util/http.js +41 -0
- package/server/src/util/limiter.js +110 -0
- package/server/src/util/log.js +94 -0
- package/server/src/util/ndjson.js +27 -0
- package/server/src/util/proc.js +80 -0
- package/server/src/util/resolveFolder.js +78 -0
- package/server/src/util/sanitize.js +45 -0
- package/server/src/util/sizedCache.js +74 -0
- package/server/src/util/textFiles.js +9 -0
- package/server/src/util/tokens.js +21 -0
- package/server/src/version.js +12 -0
- package/web/dist/LICENSE.txt +21 -0
- package/web/dist/THIRD-PARTY-LICENSES.txt +2267 -0
- package/web/dist/assets/ArasakaGame-B0V_LZLl.css +1 -0
- package/web/dist/assets/ArasakaGame-C1Wkmqik.js +36 -0
- package/web/dist/assets/index-BgL1OaU4.css +2 -0
- package/web/dist/assets/index-Ec2XBK4-.js +66 -0
- package/web/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/web/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { RISK_ORDER, RISK_RULES_VERSION } from "../analyze/risk.js";
|
|
3
|
+
import { basename } from "../discovery/tree.js";
|
|
4
|
+
import { normalizeSkillName, PluginUnavailableError, skillId } from "../indexer.js";
|
|
5
|
+
import { installSummary } from "../install/summary.js";
|
|
6
|
+
import { getSettings } from "../settings.js";
|
|
7
|
+
import { classifySkillsShSource } from "../sources/skillsSh.js";
|
|
8
|
+
import { pool, Semaphore, sleep, withTimeout } from "../util/limiter.js";
|
|
9
|
+
import { koreanSuggestion } from "./koreanTerms.js";
|
|
10
|
+
import { blendRelevance, bm25ToRelevance, freshInstalls, freshness, GENERIC_WORDS, lexicalMatch, queryTokens, popularity, relevanceTier, totalScore, TRUST_SCORE, trustTier, } from "./rank.js";
|
|
11
|
+
import { buildResultItem, githubSource, marketplaceSource, mergeSources, storeLookup, } from "./result.js";
|
|
12
|
+
/**
|
|
13
|
+
* Part of the search cache key: bump it whenever what a cached search replays changes shape (results fields,
|
|
14
|
+
* warning fields) or meaning, so older cached searches are not shown. test/pipeline.test.ts pins the shape.
|
|
15
|
+
*/
|
|
16
|
+
export const RESULTS_VERSION = 21;
|
|
17
|
+
const SOURCE_NAME_KO = { local: '내 PC 색인', skillssh: 'skills.sh', github: 'GitHub', repos: '같은 이름 저장소' };
|
|
18
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const MAX_ENRICH = 60;
|
|
20
|
+
const MAX_RESULTS = 50;
|
|
21
|
+
/** Lookups still running this long after the search started are left to finish in the background. */
|
|
22
|
+
const SEARCH_DEADLINE_MS = 18_000;
|
|
23
|
+
const ENRICH_CONCURRENCY = 24;
|
|
24
|
+
/** How long after collecting to wait for lookups before sending results without the stragglers. */
|
|
25
|
+
const INTERIM_WAIT_MS = 2000;
|
|
26
|
+
/** GitHub code-search hits looked up as soon as the search returns (the rest only if they rank in the top). */
|
|
27
|
+
const GITHUB_EARLY_LOOKUPS = 30;
|
|
28
|
+
/** How long (from the start) the enrich step waits for GitHub code search before going on without it. */
|
|
29
|
+
const GITHUB_COLLECT_WAIT_MS = 4000;
|
|
30
|
+
/** Results that missed some candidates are cached briefly; those candidates finish indexing in the background. */
|
|
31
|
+
const PARTIAL_CACHE_MS = 60 * 1000;
|
|
32
|
+
/**
|
|
33
|
+
* Candidates whose stored star count (a day or two old) is below this share of the minimum are dropped before
|
|
34
|
+
* the top-MAX_ENRICH cut and never looked up; closer to the minimum the fresh count in buildResults decides.
|
|
35
|
+
*/
|
|
36
|
+
const STAR_PREFILTER_SHARE = 0.8;
|
|
37
|
+
/** Characters of a description (and of when_to_use) that word matching reads: the SKILL.md spec's limit. */
|
|
38
|
+
const MATCH_TEXT_MAX = 1024;
|
|
39
|
+
/** Characters of a skill name that word matching reads (the spec allows 64; some real names run longer). */
|
|
40
|
+
const MATCH_NAME_MAX = 128;
|
|
41
|
+
/** Stored star counts older than this are treated as unknown (checked again before a lookup). */
|
|
42
|
+
const STORED_STARS_MAX_AGE_MS = 7 * DAY_MS;
|
|
43
|
+
const STOPWORDS = new Set('a an the and or for to of in on with from by using use uses skill skills claude code tool tools that this is are be my your how what into as at it its via any can will should'.split(' '));
|
|
44
|
+
function ftsTokens(texts) {
|
|
45
|
+
const collect = (keepGeneric) => {
|
|
46
|
+
const tokens = new Set();
|
|
47
|
+
const ok = (w) => w.length >= 2 && (!STOPWORDS.has(w) || (keepGeneric && GENERIC_WORDS.has(w)));
|
|
48
|
+
for (const t of texts) {
|
|
49
|
+
const lower = t.toLowerCase();
|
|
50
|
+
for (const m of lower.matchAll(/[\p{L}\p{N}]+/gu))
|
|
51
|
+
if (ok(m[0]))
|
|
52
|
+
tokens.add(m[0]);
|
|
53
|
+
// ASCII runs inside mixed Korean words ("pdf에서" → "pdf")
|
|
54
|
+
for (const m of lower.matchAll(/[a-z0-9]+/g))
|
|
55
|
+
if (ok(m[0]))
|
|
56
|
+
tokens.add(m[0]);
|
|
57
|
+
}
|
|
58
|
+
return tokens;
|
|
59
|
+
};
|
|
60
|
+
// a query of stopwords only ("skill", "claude code") is searched as typed; dropping them left no local search
|
|
61
|
+
let tokens = collect(false);
|
|
62
|
+
if (tokens.size === 0)
|
|
63
|
+
tokens = collect(true);
|
|
64
|
+
return [...tokens].slice(0, 24).map((t) => `"${t.replace(/"/g, '')}"`);
|
|
65
|
+
}
|
|
66
|
+
/** Every word of the query is a stopword and one names something ("skill", "tool use"): searched, but broad. */
|
|
67
|
+
export function isBroadQuery(query) {
|
|
68
|
+
const words = query.toLowerCase().match(/[a-z0-9]+/g)?.filter((w) => w.length >= 2) ?? [];
|
|
69
|
+
return words.every((w) => STOPWORDS.has(w)) && words.some((w) => GENERIC_WORDS.has(w)) && !/\p{Script=Hangul}/u.test(query);
|
|
70
|
+
}
|
|
71
|
+
/** OR-query of distinct tokens for FTS5 (each token quoted, so no FTS syntax leaks through). */
|
|
72
|
+
export function ftsQuery(texts) {
|
|
73
|
+
const list = ftsTokens(texts);
|
|
74
|
+
return list.length ? list.join(' OR ') : null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The same tokens, all required: searched first so skills with every word lead (null for one word). A word
|
|
78
|
+
* with a Korean ending ("pdf에서") is left out, as its Latin part ("pdf") is already a token.
|
|
79
|
+
*/
|
|
80
|
+
export function ftsAllQuery(texts) {
|
|
81
|
+
const list = ftsTokens(texts).filter((t) => !(/[a-z0-9]/.test(t) && /\p{Script=Hangul}/u.test(t)));
|
|
82
|
+
return list.length >= 2 ? list.join(' AND ') : null;
|
|
83
|
+
}
|
|
84
|
+
export function normalizeQuery(q) {
|
|
85
|
+
return q.normalize('NFC').trim().replace(/\s+/g, ' ').toLowerCase();
|
|
86
|
+
}
|
|
87
|
+
/** Words of the query for GitHub code search, stopwords dropped. */
|
|
88
|
+
export function queryKeywords(q) {
|
|
89
|
+
const words = q.match(/[\p{L}\p{N}][\p{L}\p{N}._-]*/gu) ?? [];
|
|
90
|
+
const kept = words.filter((w) => w.length >= 2 && !STOPWORDS.has(w.toLowerCase()));
|
|
91
|
+
return (kept.length ? kept : words).slice(0, 3);
|
|
92
|
+
}
|
|
93
|
+
const posScore = (i, n) => (n <= 1 ? 1 : 1 - i / n);
|
|
94
|
+
function prelim(c) {
|
|
95
|
+
const vals = Object.values(c.pos).filter((v) => v != null);
|
|
96
|
+
if (vals.length === 0)
|
|
97
|
+
return 0;
|
|
98
|
+
return Math.max(...vals) + 0.1 * (vals.length - 1);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* R: the engines' signal (bm25 against the best/worst hit, position in the skills.sh / GitHub lists)
|
|
102
|
+
* blended with how many query words appear in the skill name and description.
|
|
103
|
+
*/
|
|
104
|
+
function relevance(c, bestBm25, worstBm25, m) {
|
|
105
|
+
const parts = [];
|
|
106
|
+
if (c.bm25 != null)
|
|
107
|
+
parts.push(bm25ToRelevance(c.bm25, bestBm25, worstBm25));
|
|
108
|
+
if (c.pos.marketplace != null && c.bm25 == null)
|
|
109
|
+
parts.push(0.8 * c.pos.marketplace);
|
|
110
|
+
if (c.pos.skillssh != null)
|
|
111
|
+
parts.push(0.8 * c.pos.skillssh); // fuzzy engine: rank alone is not enough
|
|
112
|
+
if (c.pos.github != null)
|
|
113
|
+
parts.push(0.5 * c.pos.github);
|
|
114
|
+
const base = parts.length ? Math.max(...parts) : 0;
|
|
115
|
+
return blendRelevance(base, m);
|
|
116
|
+
}
|
|
117
|
+
function cacheKey(query, filters, extra) {
|
|
118
|
+
const f = {
|
|
119
|
+
sources: [...(filters.sources ?? ['marketplace', 'skillssh', 'github'])].sort(),
|
|
120
|
+
maxRisk: filters.maxRisk ?? 'medium',
|
|
121
|
+
marketplaces: filters.marketplaces ? [...filters.marketplaces].sort() : null,
|
|
122
|
+
};
|
|
123
|
+
return 'search:' + createHash('sha256').update(JSON.stringify({ q: normalizeQuery(query), f, extra })).digest('hex');
|
|
124
|
+
}
|
|
125
|
+
export async function runSearch(deps, query, filters, emit) {
|
|
126
|
+
const t0 = Date.now();
|
|
127
|
+
const { ctx } = deps;
|
|
128
|
+
const { store, indexer, github } = ctx;
|
|
129
|
+
const settings = getSettings(store);
|
|
130
|
+
// the setting, unless this search lifted it ("별 N개 미만 숨김 · 보기")
|
|
131
|
+
const minStars = filters.minStars ?? settings.minStars;
|
|
132
|
+
const sources = new Set(filters.sources?.length ? filters.sources : ['marketplace', 'skillssh', 'github']);
|
|
133
|
+
const maxRisk = filters.maxRisk ?? 'medium';
|
|
134
|
+
// `r` is the result-item shape; bump it when items gain fields so cached searches are rebuilt.
|
|
135
|
+
const key = cacheKey(query, filters, { w: settings.weights, t: !!ctx.config.githubToken, s: minStars, r: RESULTS_VERSION, k: RISK_RULES_VERSION });
|
|
136
|
+
// The English query for a Korean one goes out first, cached search or not: it comes from the query alone,
|
|
137
|
+
// and the button helps most exactly when there is little else to show.
|
|
138
|
+
const suggestion = koreanSuggestion(query);
|
|
139
|
+
const suggestQuery = suggestion?.query ?? null;
|
|
140
|
+
if (suggestion)
|
|
141
|
+
emit({ type: 'suggest', query: suggestion.query, dropped: suggestion.dropped });
|
|
142
|
+
const cached = store.getCached(key, DAY_MS);
|
|
143
|
+
const fresh = cached && !(cached.ttlMs && cached.cachedAt && Date.now() - cached.cachedAt > cached.ttlMs);
|
|
144
|
+
if (cached && fresh) {
|
|
145
|
+
for (const w of cached.warnings)
|
|
146
|
+
emit(w);
|
|
147
|
+
emit(cached.results);
|
|
148
|
+
emit({ type: 'done', cached: true, elapsedMs: Date.now() - t0 });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const warnings = [];
|
|
152
|
+
const warn = (source, message, severity) => {
|
|
153
|
+
const w = { type: 'warning', source, message, severity };
|
|
154
|
+
warnings.push(w);
|
|
155
|
+
emit(w);
|
|
156
|
+
};
|
|
157
|
+
const timings = { collect: 0, enrich: 0, local: 0, skillssh: 0, github: 0, repos: 0, stars: 0 };
|
|
158
|
+
/** Repos whose final star check missed the deadline (their stored count was used). */
|
|
159
|
+
let starsLate = 0;
|
|
160
|
+
/** Slowest candidate resolutions, for the log line. */
|
|
161
|
+
const slow = [];
|
|
162
|
+
if (/\p{Script=Hangul}/u.test(query)) {
|
|
163
|
+
warn('query', '스킬 설명이 대부분 영어라 한국어 검색어는 결과가 적거나 부정확합니다. 영어로 검색해 보세요 (예: extract tables from pdf).', 'urgent');
|
|
164
|
+
}
|
|
165
|
+
/** Whether the query has an English word at all (a purely Korean one can't match the English descriptions word for word). */
|
|
166
|
+
const latinQuery = queryTokens(query).some((t) => /[a-z0-9]/.test(t));
|
|
167
|
+
/** Above the risk level this search shows (default: high is hidden). Applied on every path that shows skills. */
|
|
168
|
+
const tooRisky = (s) => RISK_ORDER[s.risk_level ?? 'low'] > RISK_ORDER[maxRisk];
|
|
169
|
+
// Only the first MATCH_TEXT_MAX chars of each field count, the spec's description limit: a keyword list
|
|
170
|
+
// stuffed past it (the longest stored description is 13.5K chars) would otherwise buy a higher tier.
|
|
171
|
+
const capped = (t) => (t ?? '').slice(0, MATCH_TEXT_MAX);
|
|
172
|
+
// names are capped too (the spec allows 64 chars), or a name stuffed with keywords would buy the name tier
|
|
173
|
+
const matchOf = (c) => lexicalMatch(query, c.skill.name.slice(0, MATCH_NAME_MAX), [capped(c.skill.description), capped(c.skill.when_to_use)].join(' '));
|
|
174
|
+
/**
|
|
175
|
+
* Not one query word in the name or description (user decision A): mostly skills.sh's semantic matches and
|
|
176
|
+
* GitHub code-search hits on a SKILL.md body. Kept: anything the local index matched (`pos.marketplace`: its
|
|
177
|
+
* full-text search with stemming, a plugin matched by name, the repo named like a one-word query) and
|
|
178
|
+
* searches without an English word.
|
|
179
|
+
*/
|
|
180
|
+
const noQueryWord = (c, m) => m.any === 0 && latinQuery && c.pos.marketplace == null;
|
|
181
|
+
// 1. collect
|
|
182
|
+
emit({ type: 'stage', stage: 'collect', message: '마켓플레이스·skills.sh·GitHub에서 후보를 모으는 중' });
|
|
183
|
+
const match = ftsQuery([query]);
|
|
184
|
+
const allMatch = ftsAllQuery([query]);
|
|
185
|
+
const cands = new Map();
|
|
186
|
+
const upsert = (k, init) => {
|
|
187
|
+
let c = cands.get(k);
|
|
188
|
+
if (!c)
|
|
189
|
+
cands.set(k, (c = init()));
|
|
190
|
+
return c;
|
|
191
|
+
};
|
|
192
|
+
const blank = (k, skill, pending = null) => ({
|
|
193
|
+
key: k,
|
|
194
|
+
skill,
|
|
195
|
+
sources: [],
|
|
196
|
+
installs: null,
|
|
197
|
+
bm25: null,
|
|
198
|
+
pos: {},
|
|
199
|
+
pending,
|
|
200
|
+
});
|
|
201
|
+
const lookup = storeLookup(store);
|
|
202
|
+
// The minimum-stars setting, applied early with the stored counts so low-star candidates neither take the
|
|
203
|
+
// top-MAX_ENRICH places of popular ones nor cost a lookup. buildResults still checks every result with
|
|
204
|
+
// fresh counts; these only decide what gets that far.
|
|
205
|
+
const storedStars = new Map();
|
|
206
|
+
const storedStarsOf = (repo) => {
|
|
207
|
+
const k = repo.toLowerCase();
|
|
208
|
+
if (!storedStars.has(k)) {
|
|
209
|
+
const row = store.getRepo(repo);
|
|
210
|
+
const fresh = row?.fetched_at && Date.now() - Date.parse(row.fetched_at) < STORED_STARS_MAX_AGE_MS;
|
|
211
|
+
storedStars.set(k, fresh ? (row.stars ?? null) : null);
|
|
212
|
+
}
|
|
213
|
+
return storedStars.get(k);
|
|
214
|
+
};
|
|
215
|
+
// Official and partner skills skip the star minimum (U1): Anthropic chose them, and most come from plugin
|
|
216
|
+
// repos with few stars or none counted (1,115 of 1,158 official marketplace skills were hidden).
|
|
217
|
+
const starsExempt = (c) => {
|
|
218
|
+
const tier = c.skill ? trustTier(c.skill, lookup.plugin(c.skill.plugin_id)) : pendingTier(c);
|
|
219
|
+
return tier === 'official' || tier === 'partner';
|
|
220
|
+
};
|
|
221
|
+
const belowMinStars = (c, share) => {
|
|
222
|
+
const repo = minStars > 0 && !starsExempt(c) ? candidateRepo(c) : null;
|
|
223
|
+
const st = repo ? storedStarsOf(repo) : null;
|
|
224
|
+
return st != null && st < minStars * share;
|
|
225
|
+
};
|
|
226
|
+
/** Candidates left out for stars before buildResults; they count as hidden low-star results. */
|
|
227
|
+
const droppedForStars = new Set();
|
|
228
|
+
const dropForStars = (c) => {
|
|
229
|
+
if (!belowMinStars(c, STAR_PREFILTER_SHARE))
|
|
230
|
+
return false;
|
|
231
|
+
droppedForStars.add(c);
|
|
232
|
+
return true;
|
|
233
|
+
};
|
|
234
|
+
// Lookups for candidates that are not in the local index start as soon as their source answers, so
|
|
235
|
+
// skills.sh hits are being resolved while the (slower) GitHub code search is still running.
|
|
236
|
+
const late = [];
|
|
237
|
+
/**
|
|
238
|
+
* Opens once the local search has run. The network requests leave before it, but their answers (even instant
|
|
239
|
+
* cached ones) merge only after it, so the first cards are the local ones, as before.
|
|
240
|
+
*/
|
|
241
|
+
let openLocalGate;
|
|
242
|
+
const localGate = new Promise((r) => (openLocalGate = r));
|
|
243
|
+
/** Indexed candidates scored without a lookup: the top of the ranking, plus GitHub and repo-name hits that arrive after it was cut. */
|
|
244
|
+
const ready = [];
|
|
245
|
+
let enrichStarted = false;
|
|
246
|
+
const addLateIndexed = (list) => {
|
|
247
|
+
if (!enrichStarted)
|
|
248
|
+
return;
|
|
249
|
+
for (const c of list)
|
|
250
|
+
if (!c.pending && c.skill && !ready.includes(c) && !dropForStars(c))
|
|
251
|
+
ready.push(c);
|
|
252
|
+
};
|
|
253
|
+
const outcomes = { found: 0, missing: 0, error: 0, timeout: 0, lowstars: 0 };
|
|
254
|
+
const deadline = t0 + (deps.deadlineMs ?? SEARCH_DEADLINE_MS);
|
|
255
|
+
const slots = new Semaphore(ENRICH_CONCURRENCY);
|
|
256
|
+
const started = new Set();
|
|
257
|
+
const lookups = [];
|
|
258
|
+
const errors = [];
|
|
259
|
+
// "Nothing installable here" is remembered for a day (perf debate 1-2): after a restart the same search read
|
|
260
|
+
// whole trees again (kubernetes, 10MB) and asked deleted repos twice. A push to the repo since (its stored
|
|
261
|
+
// pushed_at) makes the verdict stale. Kept in search_cache, which a restart does not clear for these keys.
|
|
262
|
+
const knownMissing = (key, repo) => {
|
|
263
|
+
const at = store.getCachedAt(key, DAY_MS);
|
|
264
|
+
if (!at)
|
|
265
|
+
return false;
|
|
266
|
+
const pushed = repo ? store.getRepo(repo)?.pushed_at : null;
|
|
267
|
+
return !(pushed && Date.parse(pushed) > Date.parse(at));
|
|
268
|
+
};
|
|
269
|
+
const resolveCandidate = async (c) => {
|
|
270
|
+
const p = c.pending;
|
|
271
|
+
const ss = p.type === 'skillssh' ? classifySkillsShSource(p.hit.source) : null;
|
|
272
|
+
const [missKey, missRepo] = p.type === 'github'
|
|
273
|
+
? [`miss:v1:gh:${p.repo.toLowerCase()}:${p.path}`, p.repo]
|
|
274
|
+
: p.type === 'skillssh' && ss?.kind === 'github'
|
|
275
|
+
? [`miss:v1:ss:${ss.repo.toLowerCase()}:${normalizeSkillName(p.hit.name)}:${p.hit.skillId ?? ''}`, ss.repo]
|
|
276
|
+
: [null, null];
|
|
277
|
+
if (missKey && knownMissing(missKey, missRepo))
|
|
278
|
+
return 'missing';
|
|
279
|
+
const missed = () => {
|
|
280
|
+
if (missKey)
|
|
281
|
+
store.putCached(missKey, 1);
|
|
282
|
+
return 'missing';
|
|
283
|
+
};
|
|
284
|
+
// A repo never seen before: one metadata call (cached a day) is cheaper than reading its tree and SKILL.md.
|
|
285
|
+
const repo = minStars > 0 && !starsExempt(c) ? candidateRepo(c) : null;
|
|
286
|
+
if (repo && storedStarsOf(repo) == null) {
|
|
287
|
+
const meta = await withTimeout(github.getRepoMeta(repo), Math.max(0, deadline - Date.now()), 'timeout').catch(() => null);
|
|
288
|
+
// other candidates from this repo are then decided in startLookup without waiting for a slot
|
|
289
|
+
if (meta?.stars != null)
|
|
290
|
+
storedStars.set(repo.toLowerCase(), meta.stars);
|
|
291
|
+
if (meta?.stars != null && meta.stars < minStars) {
|
|
292
|
+
droppedForStars.add(c);
|
|
293
|
+
return 'lowstars';
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
let work;
|
|
297
|
+
if (p.type === 'plugin') {
|
|
298
|
+
work = indexer.indexPlugin(p.plugin.id).then(() => {
|
|
299
|
+
let skills = match ? store.searchSkillsInPlugin(match, p.plugin.id, 3).map((x) => x.skill) : [];
|
|
300
|
+
if (skills.length === 0)
|
|
301
|
+
skills = store.listSkillsByPlugin(p.plugin.id).slice(0, 2);
|
|
302
|
+
return skills;
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
else if (p.type === 'skillssh') {
|
|
306
|
+
const src = classifySkillsShSource(p.hit.source);
|
|
307
|
+
work =
|
|
308
|
+
src?.kind === 'github'
|
|
309
|
+
? indexer.findInRepo(src.repo, p.hit.name, p.hit.skillId).then((s) => (s ? [s] : []))
|
|
310
|
+
: Promise.resolve(src?.kind === 'domain' ? [indexer.wellKnownRow(src.domain, p.hit.name, p.hit.installs)] : []);
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
work = indexer.findByPath(p.repo, p.path).then((s) => (s ? [s] : []));
|
|
314
|
+
}
|
|
315
|
+
work.catch(() => { }); // a timed-out lookup keeps running and stores its row for the next search
|
|
316
|
+
try {
|
|
317
|
+
const skills = await withTimeout(work, Math.max(0, deadline - Date.now()), 'timeout');
|
|
318
|
+
if (skills.length === 0)
|
|
319
|
+
return missed();
|
|
320
|
+
if (p.type === 'plugin') {
|
|
321
|
+
const plugin = store.getPlugin(p.plugin.id) ?? p.plugin;
|
|
322
|
+
for (const s of skills)
|
|
323
|
+
late.push({ ...blank(s.id, s), sources: [marketplaceSource(s, plugin)], pos: { ...c.pos } });
|
|
324
|
+
}
|
|
325
|
+
else {
|
|
326
|
+
late.push({ ...c, key: skills[0].id, skill: skills[0], pending: null });
|
|
327
|
+
}
|
|
328
|
+
return 'found';
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
const msg = String(err?.message ?? err);
|
|
332
|
+
if (msg === 'timeout')
|
|
333
|
+
return 'timeout';
|
|
334
|
+
// A deleted or renamed repo is gone for good, not a hiccup.
|
|
335
|
+
if (err instanceof PluginUnavailableError || /저장소를 찾을 수 없습니다|tree 40[49]|tree 422/.test(msg))
|
|
336
|
+
return missed();
|
|
337
|
+
errors.push(msg.slice(0, 100));
|
|
338
|
+
return 'error';
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const startLookup = (c) => {
|
|
342
|
+
if (!c.pending || started.has(c) || dropForStars(c))
|
|
343
|
+
return;
|
|
344
|
+
started.add(c);
|
|
345
|
+
const p = c.pending;
|
|
346
|
+
const what = p.type === 'plugin' ? `plugin:${p.plugin.id}` : p.type === 'skillssh' ? `ss:${p.hit.source}/${p.hit.name}` : `gh:${p.repo}/${p.path}`;
|
|
347
|
+
lookups.push(slots.run(async () => {
|
|
348
|
+
const t = Date.now();
|
|
349
|
+
const outcome = await resolveCandidate(c);
|
|
350
|
+
outcomes[outcome]++;
|
|
351
|
+
slow.push({ what, ms: Date.now() - t, outcome });
|
|
352
|
+
}));
|
|
353
|
+
};
|
|
354
|
+
// One-word queries are often a name ("rea"). The repo with that name is shown as a result of its own, and
|
|
355
|
+
// all its skills join the candidates, even ones whose text never contains the word (morluto/rea's main
|
|
356
|
+
// skill is "reverse-engineer-anything").
|
|
357
|
+
const word = navigationalWord(query);
|
|
358
|
+
const repoInfo = new Map();
|
|
359
|
+
const addRepoNameSkills = () => {
|
|
360
|
+
if (!word)
|
|
361
|
+
return [];
|
|
362
|
+
return store.findSkillsByRepoName(word).map((s) => {
|
|
363
|
+
const c = upsert(s.id, () => blank(s.id, s));
|
|
364
|
+
const plugin = lookup.plugin(s.plugin_id);
|
|
365
|
+
const source = plugin ? marketplaceSource(s, plugin) : githubSource(s);
|
|
366
|
+
if (source)
|
|
367
|
+
c.sources = mergeSources(c.sources, [source]);
|
|
368
|
+
c.pos.marketplace = 1;
|
|
369
|
+
return c;
|
|
370
|
+
});
|
|
371
|
+
};
|
|
372
|
+
// Not waited for before the enrich step (a repo search plus indexing a new repo can take up to 14s); its
|
|
373
|
+
// skills join the results when it finishes.
|
|
374
|
+
const collectRepos = async () => {
|
|
375
|
+
if (!word)
|
|
376
|
+
return null;
|
|
377
|
+
try {
|
|
378
|
+
// repository search can filter by stars and returns description and topics for the repo card
|
|
379
|
+
const hits = await withTimeout(github.searchRepos(`${word} in:name stars:>=${Math.max(1, minStars)}`, 30, { maxWaitMs: 4000 }), 6000, 'timeout');
|
|
380
|
+
for (const r of hits)
|
|
381
|
+
if (r.repo.split('/')[1]?.toLowerCase() === word)
|
|
382
|
+
repoInfo.set(r.repo, r);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
// GitHub search unavailable: repos already in the local index still count
|
|
386
|
+
}
|
|
387
|
+
await localGate;
|
|
388
|
+
for (const s of store.findSkillsByRepoName(word)) {
|
|
389
|
+
if (s.repo && ![...repoInfo.keys()].some((k) => k.toLowerCase() === s.repo.toLowerCase())) {
|
|
390
|
+
repoInfo.set(s.repo, { stars: store.getRepo(s.repo)?.stars ?? null, description: null, topics: [] });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// Found on GitHub but never indexed: read its skills now (a slow repo keeps indexing in the background).
|
|
394
|
+
// One read that found no skills is remembered for a day like a missing lookup (perf debate 1-2).
|
|
395
|
+
const noSkillKey = (r) => `noskill:v1:${r.toLowerCase()}`;
|
|
396
|
+
const unindexed = [...repoInfo.keys()].slice(0, 3).filter((r) => store.findSkillsByRepo(r).length === 0 && !knownMissing(noSkillKey(r), r));
|
|
397
|
+
await Promise.all(unindexed.map((r) => {
|
|
398
|
+
const job = indexer.indexRepo(r);
|
|
399
|
+
job.then((res) => (res.skills === 0 && !res.unchanged ? store.putCached(noSkillKey(r), 1) : undefined)).catch(() => { });
|
|
400
|
+
return withTimeout(job, 8000, 'timeout').catch(() => 0);
|
|
401
|
+
}));
|
|
402
|
+
addLateIndexed(addRepoNameSkills());
|
|
403
|
+
return { state: 'done', count: repoInfo.size };
|
|
404
|
+
};
|
|
405
|
+
const collectLocal = async () => {
|
|
406
|
+
if (!sources.has('marketplace') || !match)
|
|
407
|
+
return null;
|
|
408
|
+
// Marketplace skills plus skills indexed straight from GitHub repos (popular-repo collection, earlier lookups).
|
|
409
|
+
// Skills with every query word come first, then the rest of the OR matches ("find security holes": a rare
|
|
410
|
+
// word alone, "hole" in Pi-hole, no longer outranks a skill about security holes). bm25 is the same in both.
|
|
411
|
+
const all = (allMatch ? store.searchSkillsFts(allMatch, 120, filters.marketplaces) : []).filter((h) => h.skill.origin !== 'wellknown');
|
|
412
|
+
const allIds = new Set(all.map((h) => h.skill.id));
|
|
413
|
+
// 120 skills with every word already fill the list; the OR query would only be cut off
|
|
414
|
+
const rest = all.length >= 120 ? [] : store.searchSkillsFts(match, 120, filters.marketplaces);
|
|
415
|
+
const hits = [...all, ...rest.filter((h) => !allIds.has(h.skill.id) && h.skill.origin !== 'wellknown')].slice(0, 120);
|
|
416
|
+
hits.forEach((h, i) => {
|
|
417
|
+
const c = upsert(h.skill.id, () => blank(h.skill.id, h.skill));
|
|
418
|
+
const plugin = lookup.plugin(h.skill.plugin_id);
|
|
419
|
+
const source = plugin ? marketplaceSource(h.skill, plugin) : githubSource(h.skill);
|
|
420
|
+
if (source)
|
|
421
|
+
c.sources = mergeSources(c.sources, [source]);
|
|
422
|
+
c.bm25 = c.bm25 == null ? h.bm25 : Math.min(c.bm25, h.bm25);
|
|
423
|
+
c.pos.marketplace = Math.max(c.pos.marketplace ?? 0, posScore(i, hits.length));
|
|
424
|
+
});
|
|
425
|
+
addRepoNameSkills();
|
|
426
|
+
const pluginHits = store.searchPluginsFts(match, 20, filters.marketplaces);
|
|
427
|
+
// the matching skills of every indexed plugin hit in one query
|
|
428
|
+
const inPlugins = store.searchSkillsInPlugins(match, pluginHits.filter((h) => h.plugin.skills_indexed_at).map((h) => h.plugin.id), 3);
|
|
429
|
+
pluginHits.forEach((h, i) => {
|
|
430
|
+
const score = 0.9 * posScore(i, pluginHits.length);
|
|
431
|
+
if (h.plugin.skills_indexed_at) {
|
|
432
|
+
let skills = (inPlugins.get(h.plugin.id) ?? []).map((x) => x.skill);
|
|
433
|
+
if (skills.length === 0)
|
|
434
|
+
skills = store.listSkillsByPlugin(h.plugin.id).slice(0, 2);
|
|
435
|
+
for (const s of skills) {
|
|
436
|
+
const c = upsert(s.id, () => blank(s.id, s));
|
|
437
|
+
c.sources = mergeSources(c.sources, [marketplaceSource(s, h.plugin)]);
|
|
438
|
+
c.pos.marketplace = Math.max(c.pos.marketplace ?? 0, score);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
else if (h.plugin.repo) {
|
|
442
|
+
const c = upsert(`pl:${h.plugin.id}`, () => blank(`pl:${h.plugin.id}`, null, { type: 'plugin', plugin: h.plugin }));
|
|
443
|
+
c.pos.marketplace = Math.max(c.pos.marketplace ?? 0, score);
|
|
444
|
+
startLookup(c);
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
return { state: 'done', count: hits.length + pluginHits.length };
|
|
448
|
+
};
|
|
449
|
+
const collectSkillsSh = async () => {
|
|
450
|
+
if (!sources.has('skillssh'))
|
|
451
|
+
return null;
|
|
452
|
+
let hits;
|
|
453
|
+
try {
|
|
454
|
+
hits = await withTimeout(deps.skillsSh(query), 6000, 'timeout');
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
warn('skillssh', `skills.sh 검색 실패 (${shortErr(err)})`, 'urgent');
|
|
458
|
+
return { state: shortErr(err) === 'timeout' ? 'timeout' : 'failed', count: 0 };
|
|
459
|
+
}
|
|
460
|
+
await localGate;
|
|
461
|
+
const touched = [];
|
|
462
|
+
hits.forEach((hit, i) => {
|
|
463
|
+
const src = classifySkillsShSource(hit.source);
|
|
464
|
+
if (!src)
|
|
465
|
+
return;
|
|
466
|
+
const local = src.kind === 'github' ? matchLocal(store.findSkillsByRepo(src.repo), hit) : null;
|
|
467
|
+
const k = local ? local.id : `ss:${hit.source.toLowerCase()}:${normalizeSkillName(hit.name)}`;
|
|
468
|
+
const c = upsert(k, () => blank(k, local, local ? null : { type: 'skillssh', hit }));
|
|
469
|
+
c.sources = mergeSources(c.sources, [{ kind: 'skillssh', label: 'skills.sh', url: `https://skills.sh/${hit.id.split('/').map(encodeURIComponent).join('/')}` }]);
|
|
470
|
+
c.installs = Math.max(c.installs ?? 0, hit.installs);
|
|
471
|
+
c.pos.skillssh = Math.max(c.pos.skillssh ?? 0, posScore(i, hits.length));
|
|
472
|
+
startLookup(c);
|
|
473
|
+
touched.push(c);
|
|
474
|
+
});
|
|
475
|
+
// an answer after the collect wait joins like a late GitHub one (indexed hits as they are)
|
|
476
|
+
addLateIndexed(touched);
|
|
477
|
+
return { state: 'done', count: hits.length };
|
|
478
|
+
};
|
|
479
|
+
// Waited for at most GITHUB_COLLECT_WAIT_MS; hits arriving later still get their lookups and join the results.
|
|
480
|
+
const collectGithub = async () => {
|
|
481
|
+
if (!sources.has('github'))
|
|
482
|
+
return null;
|
|
483
|
+
if (!github.hasToken) {
|
|
484
|
+
warn('github', 'GitHub 토큰이 없어 GitHub 코드 검색을 건너뜁니다 (설정에서 토큰을 넣으면 사용됩니다)', 'note');
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
// past ~8s of queue (the 10/min limit) it gives up at once instead of taking a slot the next search needs
|
|
489
|
+
const items = await withTimeout(github.searchSkillFiles(queryKeywords(query), 50, { maxWaitMs: 8000 }), 10_000, 'timeout');
|
|
490
|
+
await localGate;
|
|
491
|
+
const hits = [];
|
|
492
|
+
items.forEach((it, i) => {
|
|
493
|
+
const dir = it.path === 'SKILL.md' ? '' : it.path.replace(/\/SKILL\.md$/, '');
|
|
494
|
+
const k = skillId(it.repo, dir);
|
|
495
|
+
const local = store.getSkill(k) ?? null;
|
|
496
|
+
const c = upsert(k, () => blank(k, local, local ? null : { type: 'github', repo: it.repo, path: it.path }));
|
|
497
|
+
const url = it.htmlUrl.startsWith('https://github.com/') ? it.htmlUrl : `https://github.com/${it.repo}`;
|
|
498
|
+
c.sources = mergeSources(c.sources, [{ kind: 'github', label: 'GitHub', url }]);
|
|
499
|
+
c.pos.github = Math.max(c.pos.github ?? 0, 0.8 * posScore(i, items.length));
|
|
500
|
+
if (i < GITHUB_EARLY_LOOKUPS)
|
|
501
|
+
startLookup(c);
|
|
502
|
+
hits.push(c);
|
|
503
|
+
});
|
|
504
|
+
// after the cut, indexed hits join as they are (no lookup needed); unindexed ones past the first 30 are left out
|
|
505
|
+
addLateIndexed(hits);
|
|
506
|
+
return { state: 'done', count: items.length };
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
warn('github', `GitHub 검색 실패 (${shortErr(err)})`, 'urgent');
|
|
510
|
+
return { state: shortErr(err) === 'timeout' ? 'timeout' : 'failed', count: 0 };
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const runSource = (name, fn) => {
|
|
514
|
+
const t = Date.now();
|
|
515
|
+
emit({ type: 'source', source: name, state: 'running' });
|
|
516
|
+
// Never rejects: GitHub and the repo search are awaited much later, and an unhandled rejection would end the process.
|
|
517
|
+
return fn()
|
|
518
|
+
.catch((err) => {
|
|
519
|
+
warn(name, `${SOURCE_NAME_KO[name]} 검색 실패 (${shortErr(err)})`, name === 'repos' ? 'note' : 'urgent');
|
|
520
|
+
return { state: 'failed', count: 0 };
|
|
521
|
+
})
|
|
522
|
+
.then((r) => {
|
|
523
|
+
timings[name] = Date.now() - t;
|
|
524
|
+
emit(r ? { type: 'source', source: name, state: r.state, count: r.count, ms: timings[name] } : { type: 'source', source: name, state: 'skipped' });
|
|
525
|
+
});
|
|
526
|
+
};
|
|
527
|
+
const ranked = () => [...cands.values()].sort((a, b) => prelim(b) - prelim(a));
|
|
528
|
+
// Early cards, sent as each source answers: only ones the final checks (stars, a query word) are likely to
|
|
529
|
+
// keep, so they don't flash and vanish; with their tier, so the page sorts them as it will the results.
|
|
530
|
+
// They carry the stored installs and stars and go through the same copy merging, so the page sorts them the
|
|
531
|
+
// way it will sort the results (without them it ordered by rank alone, and the final list reshuffled).
|
|
532
|
+
const emitCandidates = () => {
|
|
533
|
+
const shown = ranked().filter((c) => {
|
|
534
|
+
if (!c.skill?.description || tooRisky(c.skill))
|
|
535
|
+
return false;
|
|
536
|
+
// stars unknown is hidden in the end ("모르면 숨김"), so not shown early either
|
|
537
|
+
const repo = c.skill.repo;
|
|
538
|
+
if (minStars > 0 && !starsExempt(c) && (repo ? storedStarsOf(repo) : null) == null)
|
|
539
|
+
return false;
|
|
540
|
+
return !belowMinStars(c, 1) && !noQueryWord(c, matchOf(c));
|
|
541
|
+
});
|
|
542
|
+
const items = [];
|
|
543
|
+
for (const c of dedupeByContent(shown.map((c) => ({ ...c, installs: c.installs ?? freshInstalls(c.skill) })), lookup)) {
|
|
544
|
+
if (items.length >= MAX_RESULTS)
|
|
545
|
+
break;
|
|
546
|
+
const s = c.skill;
|
|
547
|
+
const st = s.repo ? storedStarsOf(s.repo) : null;
|
|
548
|
+
const item = toItem(c, lookup, { R: Math.min(1, prelim(c)), P: 0, F: freshness(s.last_commit_at), T: trust(s, lookup) }, settings.weights, st);
|
|
549
|
+
items.push({ ...item, tier: relevanceTier(matchOf(c)) });
|
|
550
|
+
}
|
|
551
|
+
if (items.length)
|
|
552
|
+
emit({ type: 'candidates', items });
|
|
553
|
+
};
|
|
554
|
+
// The local index answers in a fraction of a second, so its cards show first; skills.sh (a few seconds) is
|
|
555
|
+
// waited for; GitHub code search only up to GITHUB_COLLECT_WAIT_MS, and the repo-name search not at all.
|
|
556
|
+
// The requests are started before the local search (synchronous SQLite) instead of after it, which held them
|
|
557
|
+
// back for its whole duration (median ~250ms). They get as far as one event-loop turn takes them (sockets
|
|
558
|
+
// opening); their answers are read once the local search is done.
|
|
559
|
+
const skillsShDone = runSource('skillssh', collectSkillsSh);
|
|
560
|
+
const githubDone = runSource('github', collectGithub);
|
|
561
|
+
const reposDone = word ? runSource('repos', collectRepos) : Promise.resolve();
|
|
562
|
+
await new Promise((r) => setImmediate(r));
|
|
563
|
+
try {
|
|
564
|
+
await runSource('local', collectLocal).then(emitCandidates);
|
|
565
|
+
}
|
|
566
|
+
finally {
|
|
567
|
+
openLocalGate(); // whatever happens, or the network sources would wait forever
|
|
568
|
+
}
|
|
569
|
+
// skills.sh carries the downloads, the first sort key: the early cards are sent again as soon as it answers,
|
|
570
|
+
// not only when GitHub is also in (perf debate 1-5; the top five settled 0.3–1s sooner where they changed).
|
|
571
|
+
let collected = false;
|
|
572
|
+
void skillsShDone.then(() => {
|
|
573
|
+
if (!collected)
|
|
574
|
+
emitCandidates();
|
|
575
|
+
});
|
|
576
|
+
// skills.sh and GitHub are waited for up to the same point (it took over 4s in ~1% of measured searches);
|
|
577
|
+
// a later answer joins the results when it arrives, and that search is cached only briefly.
|
|
578
|
+
const collectWait = sleep(Math.max(0, t0 + (deps.githubWaitMs ?? GITHUB_COLLECT_WAIT_MS) - Date.now())).then(() => false);
|
|
579
|
+
const [skillsShInTime, githubInTime] = await Promise.all([
|
|
580
|
+
Promise.race([skillsShDone.then(() => true), collectWait]),
|
|
581
|
+
Promise.race([githubDone.then(() => true), collectWait]),
|
|
582
|
+
]);
|
|
583
|
+
timings.collect = Date.now() - t0;
|
|
584
|
+
collected = true;
|
|
585
|
+
emitCandidates();
|
|
586
|
+
// 2. enrich: wait for the lookups (already running), plus any top candidate not started yet
|
|
587
|
+
emit({ type: 'stage', stage: 'enrich', message: '상위 후보의 SKILL.md와 저장소 정보를 확인하는 중' });
|
|
588
|
+
const top = ranked()
|
|
589
|
+
.filter((c) => !dropForStars(c))
|
|
590
|
+
.slice(0, MAX_ENRICH);
|
|
591
|
+
ready.push(...top.filter((c) => !c.pending));
|
|
592
|
+
enrichStarted = true;
|
|
593
|
+
// Star counts for the already-indexed candidates load while the lookups run.
|
|
594
|
+
const readyRepos = [...new Set(ready.map((c) => c.skill.repo).filter((r) => !!r))];
|
|
595
|
+
void pool(readyRepos.slice(0, 15), 6, (r) => github.getRepoMeta(r).catch(() => null));
|
|
596
|
+
for (const c of top)
|
|
597
|
+
startLookup(c);
|
|
598
|
+
// GitHub and the repo-name search may still add lookups, so wait until no new ones appear.
|
|
599
|
+
const settled = (async () => {
|
|
600
|
+
await Promise.all([skillsShDone, githubDone, reposDone]);
|
|
601
|
+
for (let n = -1; n !== lookups.length;) {
|
|
602
|
+
n = lookups.length;
|
|
603
|
+
await Promise.all(lookups);
|
|
604
|
+
}
|
|
605
|
+
})();
|
|
606
|
+
// Most lookups finish within a second or two, but a giant mirror repo (tree too big for the API, so a
|
|
607
|
+
// git fetch) takes 5–7s. Show the finished results after a short wait and fold stragglers in when done.
|
|
608
|
+
const allDone = await Promise.race([settled.then(() => true), sleep(INTERIM_WAIT_MS).then(() => false)]);
|
|
609
|
+
if (!allDone)
|
|
610
|
+
emit(await buildResults([...late], false));
|
|
611
|
+
await settled;
|
|
612
|
+
if (outcomes.timeout > 0) {
|
|
613
|
+
warn('enrich', `${outcomes.timeout}개 후보는 GitHub 응답이 늦어 이번 결과에서 빠졌습니다. 확인은 계속 진행되니 잠시 뒤 다시 검색하면 포함됩니다.`, 'urgent');
|
|
614
|
+
}
|
|
615
|
+
if (outcomes.error > 0)
|
|
616
|
+
warn('enrich-skip', `GitHub에서 읽지 못한 후보 ${outcomes.error}개는 뺐습니다.`, 'note');
|
|
617
|
+
if (outcomes.missing > 0) {
|
|
618
|
+
warn('enrich-skip', `설치할 수 있는 SKILL.md가 없는 후보 ${outcomes.missing}개는 뺐습니다 (예제·템플릿이거나 이름이 바뀐 스킬).`, 'note');
|
|
619
|
+
}
|
|
620
|
+
timings.enrich = Date.now() - t0 - timings.collect;
|
|
621
|
+
// 3. score
|
|
622
|
+
emit({ type: 'stage', stage: 'score', message: '점수를 계산하는 중' });
|
|
623
|
+
const results = await buildResults(late, true);
|
|
624
|
+
emit(results);
|
|
625
|
+
if (results.items.length > 0) {
|
|
626
|
+
// A late GitHub answer skipped the lookups of its unindexed hits past the first 30, so cache it briefly too.
|
|
627
|
+
const partial = outcomes.timeout + outcomes.error > 0 || !githubInTime || !skillsShInTime || starsLate > 0;
|
|
628
|
+
store.putCached(key, { warnings, results, cachedAt: Date.now(), ...(partial ? { ttlMs: PARTIAL_CACHE_MS } : {}) });
|
|
629
|
+
}
|
|
630
|
+
const elapsedMs = Date.now() - t0;
|
|
631
|
+
const slowest = slow
|
|
632
|
+
.sort((a, b) => b.ms - a.ms)
|
|
633
|
+
.slice(0, 4)
|
|
634
|
+
.map((s) => `${s.what} ${s.ms}ms${s.outcome === 'found' ? '' : ` (${s.outcome})`}`);
|
|
635
|
+
console.log(`[search] ${elapsedMs}ms ${JSON.stringify(timings)} lookups=${JSON.stringify(outcomes)} results=${results.items.length}${allDone ? '' : ' (interim sent)'}${githubInTime ? '' : ' (github late)'}${skillsShInTime ? '' : ' (skills.sh late)'}${starsLate ? ` (stars late ${starsLate})` : ''} q=${JSON.stringify(query)}` +
|
|
636
|
+
(slowest.length ? `\n slowest: ${slowest.join(' | ')}` : '') +
|
|
637
|
+
(errors.length ? `\n errors: ${errors.slice(0, 3).join(' | ')}` : ''));
|
|
638
|
+
emit({ type: 'done', cached: false, elapsedMs });
|
|
639
|
+
/**
|
|
640
|
+
* Candidates left out for stars before buildResults. A copy of a shown skill (same SKILL.md, e.g. a low-star
|
|
641
|
+
* fork) adds its installs to it, as dedupeByContent does for the others; the rest are counted once each as
|
|
642
|
+
* hidden low-star results (a skill reached from both skills.sh and GitHub code search counts once).
|
|
643
|
+
*/
|
|
644
|
+
function foldDroppedForStars(shown) {
|
|
645
|
+
const byContent = new Map();
|
|
646
|
+
for (const c of shown)
|
|
647
|
+
if (c.skill?.skill_md_sha256)
|
|
648
|
+
byContent.set(c.skill.skill_md_sha256, c);
|
|
649
|
+
const hidden = new Set();
|
|
650
|
+
for (const c of droppedForStars) {
|
|
651
|
+
const s = c.skill;
|
|
652
|
+
const copyOf = s?.skill_md_sha256 ? byContent.get(s.skill_md_sha256) : undefined;
|
|
653
|
+
if (copyOf) {
|
|
654
|
+
if (c.installs != null)
|
|
655
|
+
copyOf.installs = (copyOf.installs ?? 0) + c.installs;
|
|
656
|
+
copyOf.sources = mergeSources(copyOf.sources, c.sources);
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
if (s && !s.description)
|
|
660
|
+
continue;
|
|
661
|
+
hidden.add(s?.skill_md_sha256 ?? droppedKey(c));
|
|
662
|
+
}
|
|
663
|
+
return hidden.size;
|
|
664
|
+
}
|
|
665
|
+
/** Scores the indexed candidates plus the resolved ones; `final` also refreshes star counts from GitHub. */
|
|
666
|
+
async function buildResults(resolved, final) {
|
|
667
|
+
const merged = mergeById([...ready, ...resolved]);
|
|
668
|
+
for (const c of merged) {
|
|
669
|
+
if (c.installs != null && c.skill)
|
|
670
|
+
store.setSkillInstalls(c.skill.id, c.installs); // the latest count, even 0 (U4)
|
|
671
|
+
if (c.installs == null && c.skill)
|
|
672
|
+
c.installs = freshInstalls(c.skill);
|
|
673
|
+
}
|
|
674
|
+
// The risk filter comes before merging copies: a hidden high-risk representative used to take its
|
|
675
|
+
// lower-risk copies with it, and high-risk candidates held places in the top MAX_ENRICH.
|
|
676
|
+
const risky = [];
|
|
677
|
+
const allowed = merged.filter((c) => (tooRisky(c.skill) ? (risky.push(c), false) : true));
|
|
678
|
+
const deduped = dedupeByContent(allowed, lookup).sort((a, b) => prelim(b) - prelim(a));
|
|
679
|
+
// Stars for every candidate repo (cached 24h; the minimum-stars filter needs them all). Interim results
|
|
680
|
+
// use what is cached. The final check stops at the search deadline (U5-1, user decision): repos not
|
|
681
|
+
// checked by then use a stored count up to 7 days old, and without one stay unknown (hidden) as before.
|
|
682
|
+
const repos = [...new Set([...deduped, ...risky].map((c) => c.skill.repo).filter((r) => !!r))];
|
|
683
|
+
const stars = new Map();
|
|
684
|
+
const tStars = Date.now();
|
|
685
|
+
if (final) {
|
|
686
|
+
let open = true;
|
|
687
|
+
const check = pool(repos, 8, async (r) => {
|
|
688
|
+
if (Date.now() >= deadline)
|
|
689
|
+
return;
|
|
690
|
+
const meta = await github.getRepoMeta(r).catch(() => null);
|
|
691
|
+
if (open)
|
|
692
|
+
stars.set(r.toLowerCase(), meta?.stars ?? null);
|
|
693
|
+
});
|
|
694
|
+
await withTimeout(check, Math.max(0, deadline - Date.now()), 'timeout').catch(() => undefined);
|
|
695
|
+
open = false;
|
|
696
|
+
timings.stars = Date.now() - tStars;
|
|
697
|
+
starsLate = repos.filter((r) => !stars.has(r.toLowerCase())).length;
|
|
698
|
+
for (const r of repos)
|
|
699
|
+
if (!stars.has(r.toLowerCase()))
|
|
700
|
+
stars.set(r.toLowerCase(), storedStarsOf(r));
|
|
701
|
+
}
|
|
702
|
+
for (const r of repos)
|
|
703
|
+
if (!stars.has(r.toLowerCase()))
|
|
704
|
+
stars.set(r.toLowerCase(), store.getRepo(r)?.stars ?? null);
|
|
705
|
+
const starsOf = (c) => (c.skill.repo ? (stars.get(c.skill.repo.toLowerCase()) ?? null) : null);
|
|
706
|
+
// Minimum GitHub stars (user setting, default 5,000). An unknown count does not pass: it may be below.
|
|
707
|
+
const starsPass = (c) => {
|
|
708
|
+
if (minStars <= 0 || starsExempt(c))
|
|
709
|
+
return true;
|
|
710
|
+
const st = starsOf(c);
|
|
711
|
+
return st != null && st >= minStars;
|
|
712
|
+
};
|
|
713
|
+
let hiddenLowStars = 0;
|
|
714
|
+
let hiddenUnknownStars = 0;
|
|
715
|
+
const finalists = deduped
|
|
716
|
+
.filter((c) => {
|
|
717
|
+
if (starsPass(c))
|
|
718
|
+
return true;
|
|
719
|
+
if (c.skill.description) {
|
|
720
|
+
if (starsOf(c) == null)
|
|
721
|
+
hiddenUnknownStars++;
|
|
722
|
+
else
|
|
723
|
+
hiddenLowStars++;
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
})
|
|
727
|
+
.slice(0, MAX_ENRICH);
|
|
728
|
+
// "위험 높음 N개 숨김" counts only what would otherwise have been shown (one per SKILL.md)
|
|
729
|
+
const hiddenHighRisk = new Set(risky.filter((c) => c.skill.description && starsPass(c) && !noQueryWord(c, matchOf(c))).map((c) => c.skill.skill_md_sha256 ?? c.skill.id)).size;
|
|
730
|
+
hiddenLowStars += foldDroppedForStars(finalists);
|
|
731
|
+
const bm = finalists.map((c) => c.bm25).filter((b) => b != null);
|
|
732
|
+
const best = bm.length ? Math.min(...bm) : 0;
|
|
733
|
+
const worst = bm.length ? Math.max(...bm) : 0;
|
|
734
|
+
const maxInstalls = Math.max(0, ...finalists.map((c) => c.installs ?? 0));
|
|
735
|
+
const maxStars = Math.max(0, ...finalists.map((c) => stars.get(c.skill.repo?.toLowerCase() ?? '') ?? 0));
|
|
736
|
+
let hiddenNoDescription = 0;
|
|
737
|
+
let hiddenNoMatch = 0;
|
|
738
|
+
const items = [];
|
|
739
|
+
for (const c of finalists) {
|
|
740
|
+
const s = c.skill;
|
|
741
|
+
// Name-only hits (well-known sources without a description) can't be judged or read.
|
|
742
|
+
if (!s.description) {
|
|
743
|
+
hiddenNoDescription++;
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
const m = matchOf(c);
|
|
747
|
+
if (noQueryWord(c, m)) {
|
|
748
|
+
hiddenNoMatch++;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
const st = s.repo ? (stars.get(s.repo.toLowerCase()) ?? null) : null;
|
|
752
|
+
const breakdown = {
|
|
753
|
+
R: relevance(c, best, worst, m),
|
|
754
|
+
P: popularity({ installs: c.installs, stars: st }, maxInstalls, maxStars),
|
|
755
|
+
F: freshness(s.last_commit_at),
|
|
756
|
+
T: trust(s, lookup),
|
|
757
|
+
};
|
|
758
|
+
items.push({ ...toItem(c, lookup, breakdown, settings.weights, st), tier: relevanceTier(m) });
|
|
759
|
+
}
|
|
760
|
+
items.sort(byNameThenPopularity(query, items));
|
|
761
|
+
const repoCards = [...repoInfo]
|
|
762
|
+
.filter(([, i]) => minStars <= 0 || (i.stars ?? 0) >= minStars)
|
|
763
|
+
.sort((a, b) => (b[1].stars ?? 0) - (a[1].stars ?? 0))
|
|
764
|
+
.slice(0, 3)
|
|
765
|
+
.map(([repo, i]) => ({
|
|
766
|
+
repo,
|
|
767
|
+
url: `https://github.com/${repo}`,
|
|
768
|
+
description: i.description,
|
|
769
|
+
stars: i.stars,
|
|
770
|
+
topics: i.topics,
|
|
771
|
+
skills: store
|
|
772
|
+
.findSkillsByRepo(repo)
|
|
773
|
+
.filter((s) => s.description && s.origin !== 'wellknown' && !tooRisky(s))
|
|
774
|
+
.slice(0, 30)
|
|
775
|
+
.map((s) => installSummary(s, lookup)),
|
|
776
|
+
}))
|
|
777
|
+
// a skill finder: a same-named repo without skills (facebook/react for "react") is not a result
|
|
778
|
+
.filter((r) => r.skills.length > 0);
|
|
779
|
+
return {
|
|
780
|
+
type: 'results',
|
|
781
|
+
items: items.slice(0, MAX_RESULTS),
|
|
782
|
+
hiddenHighRisk,
|
|
783
|
+
hiddenNoDescription,
|
|
784
|
+
hiddenLowStars,
|
|
785
|
+
hiddenUnknownStars,
|
|
786
|
+
minStars,
|
|
787
|
+
hiddenNoMatch,
|
|
788
|
+
...(isBroadQuery(query) ? { broadQuery: true } : {}),
|
|
789
|
+
repos: repoCards,
|
|
790
|
+
...(suggestQuery ? { suggestQuery } : {}),
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Result order (user decision): installs, then stars, then relevance (R). Unknown counts sort after
|
|
796
|
+
* known ones, so a skill with any installs ranks above one whose installs are unknown.
|
|
797
|
+
*/
|
|
798
|
+
export function byPopularity(a, b) {
|
|
799
|
+
return (b.installs ?? -1) - (a.installs ?? -1) || (b.stars ?? -1) - (a.stars ?? -1) || b.breakdown.R - a.breakdown.R;
|
|
800
|
+
}
|
|
801
|
+
/** The query as a single lowercase name-like word ("rea", "pdf-tools"), or null for multi-word queries. */
|
|
802
|
+
export function navigationalWord(query) {
|
|
803
|
+
const q = normalizeQuery(query);
|
|
804
|
+
return /^[a-z0-9][a-z0-9._-]{1,99}$/.test(q) ? q : null;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The main skill of the repo named like a one-word query: its most downloaded one, when that has at least twice
|
|
808
|
+
* the downloads of the next (morluto/rea's reverse-engineer-anything: 11,216, the rest none). The repo's other
|
|
809
|
+
* skills are mostly its contributors' own tooling (react/react's `.claude/skills`), and installing a whole repo
|
|
810
|
+
* counts each of its skills, so they often have about the same count (mastra-ai/mastra 2,400-2,800 each): no
|
|
811
|
+
* main skill there (user decision 2026-11-02). Ids are `gh:owner/repo:dir`.
|
|
812
|
+
*/
|
|
813
|
+
export function repoMainSkill(items, word) {
|
|
814
|
+
const [first, second] = items
|
|
815
|
+
.filter((i) => i.id.match(/^gh:[^/]+\/([^:]+):/)?.[1]?.toLowerCase() === word)
|
|
816
|
+
.map((i) => ({ id: i.id, n: i.installs ?? 0 }))
|
|
817
|
+
.sort((a, b) => b.n - a.n);
|
|
818
|
+
return first && first.n > 0 && first.n >= 2 * (second?.n ?? 0) ? first.id : null;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Typing a skill's or repo's exact name is a lookup: skills named `q` or `q-…` come first (most popular first
|
|
822
|
+
* among them), and so does the main skill of a repo named `q` (repoMainSkill). Everything else, the repo's other
|
|
823
|
+
* skills included, keeps the popularity order.
|
|
824
|
+
*/
|
|
825
|
+
export function nameMatches(item, word, main = null) {
|
|
826
|
+
const name = item.name.toLowerCase();
|
|
827
|
+
return name === word || name.startsWith(`${word}-`) || item.id === main;
|
|
828
|
+
}
|
|
829
|
+
/** Relevance tier first (user decision C, see relevanceTier), then popularity. */
|
|
830
|
+
export function byTierThenPopularity(a, b) {
|
|
831
|
+
return (b.tier ?? 0) - (a.tier ?? 0) || byPopularity(a, b);
|
|
832
|
+
}
|
|
833
|
+
/** `items`: the results being sorted, to find the same-name repo's main skill. */
|
|
834
|
+
export function byNameThenPopularity(query, items = []) {
|
|
835
|
+
const word = navigationalWord(query);
|
|
836
|
+
if (!word)
|
|
837
|
+
return byTierThenPopularity;
|
|
838
|
+
const main = repoMainSkill(items, word);
|
|
839
|
+
// Among the skills a name lookup found, popularity alone decides (morluto/rea's main skill has no "rea" in its name).
|
|
840
|
+
return (a, b) => {
|
|
841
|
+
const na = nameMatches(a, word, main);
|
|
842
|
+
const nb = nameMatches(b, word, main);
|
|
843
|
+
return Number(nb) - Number(na) || (na ? byPopularity(a, b) : byTierThenPopularity(a, b));
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/** The trust tier of a candidate not read yet, from what its source says (the repo, the plugin's marketplace). */
|
|
847
|
+
function pendingTier(c) {
|
|
848
|
+
const p = c.pending;
|
|
849
|
+
if (p?.type === 'plugin')
|
|
850
|
+
return trustTier({ repo: p.plugin.repo, dir_path: p.plugin.path }, p.plugin);
|
|
851
|
+
const repo = candidateRepo(c);
|
|
852
|
+
return repo ? trustTier({ repo, dir_path: p?.type === 'github' ? p.path : null }, null) : null;
|
|
853
|
+
}
|
|
854
|
+
/** The GitHub repo a candidate's skill lives in, known before its lookup (null for well-known sites). */
|
|
855
|
+
function candidateRepo(c) {
|
|
856
|
+
if (c.skill)
|
|
857
|
+
return c.skill.repo;
|
|
858
|
+
const p = c.pending;
|
|
859
|
+
if (!p)
|
|
860
|
+
return null;
|
|
861
|
+
if (p.type === 'plugin')
|
|
862
|
+
return p.plugin.repo;
|
|
863
|
+
if (p.type === 'github')
|
|
864
|
+
return p.repo;
|
|
865
|
+
const src = classifySkillsShSource(p.hit.source);
|
|
866
|
+
return src?.kind === 'github' ? src.repo : null;
|
|
867
|
+
}
|
|
868
|
+
/** Same key for one skill whether it came from skills.sh, GitHub code search or the index: repo + skill name. */
|
|
869
|
+
function droppedKey(c) {
|
|
870
|
+
const p = c.pending;
|
|
871
|
+
const name = c.skill ? c.skill.name : p?.type === 'skillssh' ? p.hit.name : p?.type === 'github' ? basename(p.path.replace(/\/?SKILL\.md$/, '')) : null;
|
|
872
|
+
if (p?.type === 'plugin')
|
|
873
|
+
return `pl:${p.plugin.id}`;
|
|
874
|
+
return `${candidateRepo(c)?.toLowerCase() ?? ''}:${normalizeSkillName(name ?? c.key)}`;
|
|
875
|
+
}
|
|
876
|
+
/** Folds candidates that resolved to the same skill row into one (first one wins as the base). */
|
|
877
|
+
function mergeById(list) {
|
|
878
|
+
const byId = new Map();
|
|
879
|
+
for (const c of list) {
|
|
880
|
+
const k = c.skill.id;
|
|
881
|
+
const prev = byId.get(k);
|
|
882
|
+
if (!prev) {
|
|
883
|
+
byId.set(k, { ...c, key: k, sources: [...c.sources], pos: { ...c.pos } });
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
prev.sources = mergeSources(prev.sources, c.sources);
|
|
887
|
+
prev.installs = maxNullable(prev.installs, c.installs);
|
|
888
|
+
prev.bm25 = prev.bm25 == null ? c.bm25 : c.bm25 == null ? prev.bm25 : Math.min(prev.bm25, c.bm25);
|
|
889
|
+
for (const [kind, v] of Object.entries(c.pos))
|
|
890
|
+
prev.pos[kind] = Math.max(prev.pos[kind] ?? 0, v);
|
|
891
|
+
}
|
|
892
|
+
return [...byId.values()];
|
|
893
|
+
}
|
|
894
|
+
function shortErr(err) {
|
|
895
|
+
return String(err?.message ?? err).slice(0, 120);
|
|
896
|
+
}
|
|
897
|
+
function maxNullable(a, b) {
|
|
898
|
+
if (a == null)
|
|
899
|
+
return b;
|
|
900
|
+
if (b == null)
|
|
901
|
+
return a;
|
|
902
|
+
return Math.max(a, b);
|
|
903
|
+
}
|
|
904
|
+
function trust(s, lookup) {
|
|
905
|
+
return TRUST_SCORE[trustTier(s, lookup.plugin(s.plugin_id))];
|
|
906
|
+
}
|
|
907
|
+
function matchLocal(rows, hit) {
|
|
908
|
+
const want = normalizeSkillName(hit.name);
|
|
909
|
+
const folder = hit.skillId ? normalizeSkillName(hit.skillId) : null;
|
|
910
|
+
const plugins = rows.filter((r) => r.origin !== 'wellknown');
|
|
911
|
+
return (plugins.find((r) => normalizeSkillName(r.name) === want && r.origin === 'plugin') ??
|
|
912
|
+
plugins.find((r) => normalizeSkillName(r.name) === want) ??
|
|
913
|
+
(folder ? plugins.find((r) => r.dir_path && normalizeSkillName(basename(r.dir_path)) === folder) : undefined) ??
|
|
914
|
+
null);
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* Same SKILL.md published in several places → one result, sources and installs combined. Only the same file
|
|
918
|
+
* (U4, user decision): a same name+description with a different body is another skill (an edited fork), so
|
|
919
|
+
* summing its downloads overstated both; the screen folds same-name results instead, without summing.
|
|
920
|
+
*/
|
|
921
|
+
function dedupeByContent(cands, lookup) {
|
|
922
|
+
const groups = new Map();
|
|
923
|
+
const merged = [];
|
|
924
|
+
const out = [];
|
|
925
|
+
for (const c of cands) {
|
|
926
|
+
const k = c.skill.skill_md_sha256;
|
|
927
|
+
const existing = k ? groups.get(k) : undefined;
|
|
928
|
+
if (existing)
|
|
929
|
+
existing.push(c);
|
|
930
|
+
else {
|
|
931
|
+
const g = [c];
|
|
932
|
+
if (k)
|
|
933
|
+
groups.set(k, g);
|
|
934
|
+
out.push(g);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
for (const g of out) {
|
|
938
|
+
const c = g[0];
|
|
939
|
+
if (g.length === 1) {
|
|
940
|
+
merged.push(c);
|
|
941
|
+
continue;
|
|
942
|
+
}
|
|
943
|
+
const rep = [...g].sort((a, b) => trust(b.skill, lookup) - trust(a.skill, lookup) ||
|
|
944
|
+
Number(b.skill.origin === 'plugin') - Number(a.skill.origin === 'plugin') ||
|
|
945
|
+
(b.installs ?? 0) - (a.installs ?? 0))[0];
|
|
946
|
+
const combined = { ...rep, sources: [...rep.sources], pos: { ...rep.pos } };
|
|
947
|
+
let installs = 0;
|
|
948
|
+
let anyInstalls = false;
|
|
949
|
+
for (const x of g) {
|
|
950
|
+
combined.sources = mergeSources(combined.sources, x.sources);
|
|
951
|
+
if (x.installs != null) {
|
|
952
|
+
installs += x.installs;
|
|
953
|
+
anyInstalls = true;
|
|
954
|
+
}
|
|
955
|
+
if (x.bm25 != null)
|
|
956
|
+
combined.bm25 = combined.bm25 == null ? x.bm25 : Math.min(combined.bm25, x.bm25);
|
|
957
|
+
for (const [kind, v] of Object.entries(x.pos))
|
|
958
|
+
combined.pos[kind] = Math.max(combined.pos[kind] ?? 0, v);
|
|
959
|
+
}
|
|
960
|
+
combined.installs = anyInstalls ? installs : null;
|
|
961
|
+
merged.push(combined);
|
|
962
|
+
}
|
|
963
|
+
return merged;
|
|
964
|
+
}
|
|
965
|
+
function toItem(c, lookup, breakdown, weights, stars = null) {
|
|
966
|
+
return buildResultItem({
|
|
967
|
+
skill: c.skill,
|
|
968
|
+
lookup,
|
|
969
|
+
sources: c.sources.length ? c.sources : [githubSource(c.skill) ?? { kind: 'github', label: 'GitHub', url: '' }],
|
|
970
|
+
installs: c.installs,
|
|
971
|
+
stars,
|
|
972
|
+
breakdown,
|
|
973
|
+
score: totalScore(breakdown, weights),
|
|
974
|
+
});
|
|
975
|
+
}
|