@seojin08/skill-finder 0.3.2 → 0.3.4

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +2 -2
  3. package/bin/skill-finder.mjs +4 -4
  4. package/package.json +4 -3
  5. package/seed-index.json.gz +0 -0
  6. package/server/src/app.js +3 -0
  7. package/server/src/bootstrap.js +2 -1
  8. package/server/src/config.js +16 -4
  9. package/server/src/db/schema.js +3 -0
  10. package/server/src/db/seed.js +89 -0
  11. package/server/src/db/store.js +4 -3
  12. package/server/src/discovery/genericRules.js +3 -1
  13. package/server/src/index.js +11 -0
  14. package/server/src/indexer.js +5 -1
  15. package/server/src/install/installed.js +96 -26
  16. package/server/src/recommend/readme.js +14 -2
  17. package/server/src/routes/favorites.js +55 -10
  18. package/server/src/routes/install.js +33 -7
  19. package/server/src/routes/translate.js +35 -0
  20. package/server/src/search/pipeline.js +3 -1
  21. package/server/src/search/result.js +9 -1
  22. package/server/src/security.js +4 -1
  23. package/server/src/sources/gitTree.js +11 -1
  24. package/server/src/sources/github.js +17 -2
  25. package/server/src/tasks.js +45 -5
  26. package/server/src/translate/models.js +140 -0
  27. package/web/dist/THIRD-PARTY-LICENSES.txt +6 -0
  28. package/web/dist/assets/{ArasakaGame-C1Wkmqik.js → ArasakaGame-B2qY9Lds.js} +2 -2
  29. package/web/dist/assets/__vite-browser-external-BDfy7Drd.js +1 -0
  30. package/web/dist/assets/index-BJ6vm3xw.js +66 -0
  31. package/web/dist/assets/index-BiikysiH.css +2 -0
  32. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  33. package/web/dist/assets/translator-Cyk_ZOaM.js +2 -0
  34. package/web/dist/bergamot/bergamot-translator-worker.js +2919 -0
  35. package/web/dist/bergamot/bergamot-translator-worker.wasm +0 -0
  36. package/web/dist/bergamot/translator-worker.js +475 -0
  37. package/web/dist/index.html +3 -2
  38. package/web/dist/assets/index-BgL1OaU4.css +0 -2
  39. package/web/dist/assets/index-Ec2XBK4-.js +0 -66
@@ -1,14 +1,34 @@
1
1
  import { Router } from 'express';
2
2
  import { z } from 'zod';
3
- import { freshInstalls, freshness, TRUST_SCORE, trustTier } from "../search/rank.js";
4
- import { buildResultItem, githubSource, marketplaceSource, storeLookup } from "../search/result.js";
3
+ import { installSummary } from "../install/summary.js";
4
+ import { isRepo } from "../install/validate.js";
5
+ import { indexItem, storeLookup } from "../search/result.js";
6
+ import { sanitizeLine } from "../util/sanitize.js";
5
7
  const FavoriteBody = z.object({ id: z.string().min(1).max(400) });
6
8
  /**
7
9
  * `name`: a search result not in the index yet (still being checked) is kept by it until it is. Unusable (empty,
8
10
  * not text) it is left out rather than refused, so an indexed skill still gets its star.
9
11
  */
10
- const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional() });
12
+ const FavoriteAdd = FavoriteBody.extend({ name: z.unknown().optional(), repo: z.unknown().optional() });
11
13
  const usableName = (v) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, 200) : undefined);
14
+ /** A favorite GitHub repository (user request 2026-11-03): `repo:` and the name in lower case, so one per repo. */
15
+ const REPO_PREFIX = 'repo:';
16
+ /** What the search showed of the repo; anything unusable is left out rather than refused. */
17
+ function repoMeta(v) {
18
+ const o = (v && typeof v === 'object' ? v : {});
19
+ const description = typeof o.description === 'string' && o.description.trim() ? sanitizeLine(o.description).slice(0, 500) : null;
20
+ const topics = Array.isArray(o.topics) ? o.topics.filter((x) => typeof x === 'string' && /^[a-z0-9][a-z0-9-]{0,49}$/.test(x)).slice(0, 30) : [];
21
+ return { description, topics };
22
+ }
23
+ /** One damaged row must not take the whole favorites list down with it. */
24
+ function parseJson(text) {
25
+ try {
26
+ return text ? JSON.parse(text) : null;
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ }
12
32
  /**
13
33
  * This PC's favorite skills (user decision 2026-11-02). No accounts: the app runs for one person on 127.0.0.1, so a
14
34
  * login kept only here protected nothing. The API's header and same-origin checks apply as everywhere.
@@ -19,28 +39,52 @@ export function favoritesRouter(ctx) {
19
39
  r.get('/favorites/ids', (_req, res) => {
20
40
  res.json({ ids: store.listFavorites().map((f) => f.skill_id) });
21
41
  });
22
- /** The favorites as result cards; ones whose skill left the index are listed by name. */
42
+ /** The favorites as result cards and repository cards; skills that left the index are listed by name. */
23
43
  r.get('/favorites', (_req, res) => {
24
44
  const lookup = storeLookup(store);
25
45
  const items = [];
46
+ const repos = [];
26
47
  const gone = [];
27
48
  for (const f of store.listFavorites()) {
49
+ if (f.skill_id.startsWith(REPO_PREFIX)) {
50
+ const repo = f.skill_name;
51
+ const meta = repoMeta(parseJson(f.meta_json));
52
+ repos.push({
53
+ repo,
54
+ url: `https://github.com/${repo}`,
55
+ ...meta,
56
+ stars: store.getRepo(repo)?.stars ?? null,
57
+ // as the search's repository card lists them
58
+ skills: store
59
+ .findSkillsByRepo(repo)
60
+ .filter((s) => s.description && s.origin !== 'wellknown')
61
+ .slice(0, 30)
62
+ .map((s) => installSummary(s, lookup)),
63
+ });
64
+ continue;
65
+ }
28
66
  const s = store.getSkill(f.skill_id);
29
67
  if (!s) {
30
68
  gone.push({ id: f.skill_id, name: f.skill_name });
31
69
  continue;
32
70
  }
33
- const plugin = lookup.plugin(s.plugin_id);
34
- const source = plugin ? marketplaceSource(s, plugin) : githubSource(s);
35
- const breakdown = { R: 0, P: 0, F: freshness(s.last_commit_at), T: TRUST_SCORE[trustTier(s, plugin)] };
36
- items.push(buildResultItem({ skill: s, lookup, sources: source ? [source] : [], installs: freshInstalls(s), stars: s.repo ? (store.getRepo(s.repo)?.stars ?? null) : null, breakdown, score: 0 }));
71
+ items.push(indexItem(store, lookup, s));
37
72
  }
38
- res.json({ items, gone });
73
+ res.json({ items, repos, gone });
39
74
  });
40
75
  r.post('/favorites', (req, res) => {
41
76
  const parsed = FavoriteAdd.safeParse(req.body);
42
77
  if (!parsed.success)
43
78
  return void res.status(400).json({ error: '스킬 id가 없습니다' });
79
+ if (parsed.data.id.startsWith(REPO_PREFIX)) {
80
+ const repo = parsed.data.id.slice(REPO_PREFIX.length);
81
+ if (!isRepo(repo))
82
+ return void res.status(400).json({ error: '저장소 이름이 올바르지 않습니다' });
83
+ // the name as GitHub writes it (morluto/rea), when it is a repo name and the same repo
84
+ const name = usableName(parsed.data.name);
85
+ store.addFavorite(REPO_PREFIX + repo.toLowerCase(), isRepo(name) && name.toLowerCase() === repo.toLowerCase() ? name : repo, repoMeta(parsed.data.repo));
86
+ return void res.json({ ok: true });
87
+ }
44
88
  const skill = store.getSkill(parsed.data.id);
45
89
  // not indexed: listed by name on the favorites page, as a card once the index has it
46
90
  const name = skill?.name ?? usableName(parsed.data.name);
@@ -54,7 +98,8 @@ export function favoritesRouter(ctx) {
54
98
  const parsed = FavoriteBody.safeParse({ id: req.query.id });
55
99
  if (!parsed.success)
56
100
  return void res.status(400).json({ error: '스킬 id가 없습니다' });
57
- store.removeFavorite(parsed.data.id);
101
+ // repository favorites are kept in lower case (POST)
102
+ store.removeFavorite(parsed.data.id.startsWith(REPO_PREFIX) ? parsed.data.id.toLowerCase() : parsed.data.id);
58
103
  res.json({ ok: true });
59
104
  });
60
105
  return r;
@@ -5,7 +5,8 @@ import { Router } from 'express';
5
5
  import { z } from 'zod';
6
6
  import { buildSteps, planInstall } from "../install/plan.js";
7
7
  import { executeInstall } from "../install/run.js";
8
- import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills } from "../install/installed.js";
8
+ import { existingSkillFolder, forgetPlugins, installedPluginCommits, listInstalledPlugins, pathKey, pluginCommitKey, pluginIndexInfo, pluginSkills, scanInstalledSkills, scanPluginSkills } from "../install/installed.js";
9
+ import { indexItem, storeLookup } from "../search/result.js";
9
10
  import { uninstallPlugin, uninstallSkill } from "../install/uninstall.js";
10
11
  import { isProjectDir } from "../install/validate.js";
11
12
  import { getSettings } from "../settings.js";
@@ -241,7 +242,8 @@ export function installRouter(ctx) {
241
242
  }
242
243
  const listed = await listInstalledPlugins(projectPath ?? homedir(), true);
243
244
  const commits = installedPluginCommits();
244
- const plugins = listed.plugins.map((o) => {
245
+ // with a folder, another project's project and local plugins are not this one's (user report 2026-11-04)
246
+ const plugins = listed.plugins.filter((o) => !projectPath || (o.scope !== 'project' && o.scope !== 'local') || pathKey(o.projectPath ?? '') === pathKey(projectPath)).map((o) => {
245
247
  const { scope, id: pid } = o;
246
248
  const ownProject = o.projectPath;
247
249
  // `claude plugin list` reports enabled for the folder it runs in; read the settings file that owns this install instead.
@@ -263,19 +265,43 @@ export function installRouter(ctx) {
263
265
  ...(await scanInstalledSkills(store, 'user', null)),
264
266
  ...(projectPath ? await scanInstalledSkills(store, 'project', projectPath) : []),
265
267
  ];
266
- res.json({ plugins, pluginError: listed.error, skills, projects: store.listInstalledProjects() });
268
+ // The skills of the plugins that apply here: everyone's (user, synced) and this folder's. Without a folder the
269
+ // project plugins listed above belong to other folders. One row per folder: a plugin installed for the user and
270
+ // for a project shares its install folder (code review 2026-11-04).
271
+ const applying = plugins.filter((p) => (p.scope !== 'project' && p.scope !== 'local') || projectPath);
272
+ const seen = new Set();
273
+ const pluginSkillRows = (await Promise.all(applying.map((p) => scanPluginSkills(store, { ...p, enabled: typeof p.enabled === 'boolean' ? p.enabled : null }))))
274
+ .flat()
275
+ .filter((s) => !seen.has(s.path) && seen.add(s.path));
276
+ // the index's card for each indexed one: provider, category and install method for the side filters
277
+ const lookup = storeLookup(store);
278
+ const items = {};
279
+ for (const s of [...skills, ...pluginSkillRows]) {
280
+ const row = s.skillId ? store.getSkill(s.skillId) : undefined;
281
+ if (row && !items[row.id])
282
+ items[row.id] = indexItem(store, lookup, row);
283
+ }
284
+ res.json({ plugins, pluginError: listed.error, skills, pluginSkills: pluginSkillRows, items, projects: store.listInstalledProjects() });
267
285
  });
268
286
  // What is installed, by index id, for the "설치됨" badges on results (UI debate 3-1). Project skills are those of
269
- // the default project folder in the settings. Unknown ones are simply absent: no badge, never "not installed".
270
- r.get('/installed/ids', async (_req, res) => {
271
- const project = getSettings(store).defaultProjectPath;
287
+ // `projectPath` (the folder README 추천 analyzed, user request 2026-11-04), else of the default project folder in
288
+ // the settings; with `projectPath`, project and local plugins of other folders are left out. Unknown ones are
289
+ // simply absent: no badge, never "not installed".
290
+ r.get('/installed/ids', async (req, res) => {
291
+ const asked = typeof req.query.projectPath === 'string' && req.query.projectPath.trim() ? req.query.projectPath.trim() : null;
292
+ if (asked && !isProjectDir(asked)) {
293
+ res.status(400).json({ error: '프로젝트 경로는 존재하는 디렉터리의 절대경로여야 합니다' });
294
+ return;
295
+ }
296
+ const project = asked ?? getSettings(store).defaultProjectPath;
272
297
  const listed = await listInstalledPlugins();
273
298
  const skills = [
274
299
  ...(await scanInstalledSkills(store, 'user', null)),
275
300
  ...(isProjectDir(project) ? await scanInstalledSkills(store, 'project', project) : []),
276
301
  ];
302
+ const inFolder = (p) => !asked || (p.scope !== 'project' && p.scope !== 'local') || pathKey(p.projectPath ?? '') === pathKey(asked);
277
303
  res.json({
278
- plugins: listed.plugins.map((p) => ({ id: p.id, scope: p.scope })),
304
+ plugins: listed.plugins.filter(inFolder).map((p) => ({ id: p.id, scope: p.scope })),
279
305
  skills: skills.filter((s) => s.skillId).map((s) => ({ id: s.skillId, scope: s.scope })),
280
306
  });
281
307
  });
@@ -0,0 +1,35 @@
1
+ import express, { Router } from 'express';
2
+ import { MODEL_LANGS, MODEL_URL_PATH } from "../translate/models.js";
3
+ const langOf = (v) => (MODEL_LANGS.includes(v) ? v : null);
4
+ /** The translation model for browsers without a built-in translator: its state, and the one-time download. */
5
+ export function translateRouter(ctx) {
6
+ const r = Router();
7
+ r.get('/translate/models/:lang', (req, res) => {
8
+ const lang = langOf(req.params.lang);
9
+ if (!lang)
10
+ return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
11
+ res.json(ctx.translateModels.status(lang));
12
+ });
13
+ r.post('/translate/models/:lang', (req, res) => {
14
+ const lang = langOf(req.params.lang);
15
+ if (!lang)
16
+ return void res.status(404).json({ error: '번역 모델이 없는 언어입니다' });
17
+ res.json(ctx.translateModels.start(lang));
18
+ });
19
+ return r;
20
+ }
21
+ /**
22
+ * The downloaded files and the engine's registry, outside /api: the engine fetches them without the API header.
23
+ * Read only; the registry's addresses carry each file's hash (?v=), so the files are cached for good.
24
+ */
25
+ export function translateFiles(ctx) {
26
+ const r = Router();
27
+ r.get(`${MODEL_URL_PATH}/index.json`, (_req, res) => {
28
+ res.setHeader('Cache-Control', 'no-cache');
29
+ res.json(ctx.translateModels.registry());
30
+ });
31
+ r.use(MODEL_URL_PATH, express.static(ctx.translateModels.root, { index: false, dotfiles: 'deny', maxAge: '30d', immutable: true }));
32
+ // a file that is not there is a 404, not the page's index.html (the engine saw that only as an integrity error)
33
+ r.use(MODEL_URL_PATH, (_req, res) => void res.status(404).end());
34
+ return r;
35
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { RISK_ORDER, RISK_RULES_VERSION } from "../analyze/risk.js";
3
+ import { SKILLS_CLI_VERSION } from "../config.js";
3
4
  import { basename } from "../discovery/tree.js";
4
5
  import { normalizeSkillName, PluginUnavailableError, skillId } from "../indexer.js";
5
6
  import { installSummary } from "../install/summary.js";
@@ -132,7 +133,8 @@ export async function runSearch(deps, query, filters, emit) {
132
133
  const sources = new Set(filters.sources?.length ? filters.sources : ['marketplace', 'skillssh', 'github']);
133
134
  const maxRisk = filters.maxRisk ?? 'medium';
134
135
  // `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
+ // c: cached results carry install commands naming the skills CLI version (`npx skills@x.y.z …`)
137
+ const key = cacheKey(query, filters, { w: settings.weights, t: !!ctx.config.githubToken, s: minStars, r: RESULTS_VERSION, k: RISK_RULES_VERSION, c: SKILLS_CLI_VERSION });
136
138
  // The English query for a Korean one goes out first, cached search or not: it comes from the query alone,
137
139
  // and the button helps most exactly when there is little else to show.
138
140
  const suggestion = koreanSuggestion(query);
@@ -2,7 +2,7 @@ import { RISK_ORDER } from "../analyze/risk.js";
2
2
  import { planInstall } from "../install/plan.js";
3
3
  import { truncate } from "../util/sanitize.js";
4
4
  import { categorize } from "./category.js";
5
- import { trustTier } from "./rank.js";
5
+ import { freshInstalls, freshness, TRUST_SCORE, trustTier } from "./rank.js";
6
6
  export const TRUST_LABEL = { official: '공식', partner: '파트너', community: '커뮤니티' };
7
7
  export function treeUrl(repo, ref, dir) {
8
8
  const base = `https://github.com/${repo}`;
@@ -81,6 +81,14 @@ export function storeLookup(store) {
81
81
  },
82
82
  };
83
83
  }
84
+ /** An indexed skill as a card outside a search (favorites, installed): no relevance or popularity score. */
85
+ export function indexItem(store, lookup, skill) {
86
+ const plugin = lookup.plugin(skill.plugin_id);
87
+ const source = plugin ? marketplaceSource(skill, plugin) : githubSource(skill);
88
+ const breakdown = { R: 0, P: 0, F: freshness(skill.last_commit_at), T: TRUST_SCORE[trustTier(skill, plugin)] };
89
+ const stars = skill.repo ? (store.getRepo(skill.repo)?.stars ?? null) : null;
90
+ return buildResultItem({ skill, lookup, sources: source ? [source] : [], installs: freshInstalls(skill), stars, breakdown, score: 0 });
91
+ }
84
92
  export function buildResultItem(a) {
85
93
  const { skill } = a;
86
94
  const plugin = a.lookup.plugin(skill.plugin_id);
@@ -49,5 +49,8 @@ export function securityMiddleware(opts) {
49
49
  next();
50
50
  };
51
51
  }
52
- export const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
52
+ // 'wasm-unsafe-eval': the translation engine is WebAssembly (served from here); it allows compiling WASM, not eval.
53
+ // Its worker takes the policy of its own script's response, but the page has it too for browsers that give workers
54
+ // the page's policy.
55
+ export const CSP = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; " +
53
56
  "connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'";
@@ -2,6 +2,13 @@ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { mkdir, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { run } from "../util/proc.js";
5
+ /**
6
+ * git asked for a login: GitHub answers so for a repo that is deleted or private (credentials are turned off
7
+ * here), so it is gone, not a network hiccup. It used to count as "could not read" in searches and was asked
8
+ * again on every sync (2026-11-03).
9
+ */
10
+ export class RepoUnreachableError extends Error {
11
+ }
5
12
  const REPO_RE = /^[A-Za-z0-9-]{1,39}\/[A-Za-z0-9._-]{1,100}$/;
6
13
  const SHA_RE = /^[0-9a-f]{40}$/i;
7
14
  const REF_RE = /^[A-Za-z0-9._\/-]{1,200}$/;
@@ -75,8 +82,11 @@ export async function gitTree(repo, sha, workRoot) {
75
82
  if (init.exitCode !== 0)
76
83
  throw new Error(`git init failed: ${init.stderr.trim()}`);
77
84
  const fetch = await git(['fetch', '-q', '--depth', '1', '--filter=blob:none', '--no-tags', repoUrl(repo), sha], dir);
78
- if (fetch.exitCode !== 0)
85
+ if (fetch.exitCode !== 0) {
86
+ if (/could not read Username|Repository not found/i.test(fetch.stderr))
87
+ throw new RepoUnreachableError(`저장소를 찾을 수 없습니다: ${repo}`);
79
88
  throw new Error(`git fetch failed: ${fetch.stderr.trim().split('\n')[0] || fetch.exitCode}`);
89
+ }
80
90
  if (sha === 'HEAD') {
81
91
  const rev = await git(['rev-parse', 'FETCH_HEAD'], dir);
82
92
  sha = rev.stdout.trim();
@@ -27,6 +27,8 @@ function isSafeRepoPath(path) {
27
27
  }
28
28
  /** Raw files (SKILL.md included) are read up to this many bytes; the rest is cut off. */
29
29
  export const RAW_MAX_BYTES = 512 * 1024;
30
+ /** First wait after a raw download got 429, doubled on each retry. */
31
+ const RAW_THROTTLE_WAIT_MS = 1000;
30
32
  export class GitHub {
31
33
  store;
32
34
  config;
@@ -44,6 +46,8 @@ export class GitHub {
44
46
  refCache = new Map();
45
47
  /** raw.githubusercontent.com answers bursts of hundreds of parallel downloads with 429s; keep it civil. */
46
48
  rawSlots = new Semaphore(24);
49
+ /** raw.githubusercontent.com said 429: no download starts before this time. */
50
+ rawPausedUntil = 0;
47
51
  metaInFlight = new Map();
48
52
  constructor(store, config) {
49
53
  this.store = store;
@@ -244,8 +248,19 @@ export class GitHub {
244
248
  const url = `https://raw.githubusercontent.com/${repo}/${ref}/${path.split('/').map(encodeURIComponent).join('/')}`;
245
249
  for (let attempt = 0;; attempt++) {
246
250
  try {
247
- const res = await this.rawSlots.run(() => fetchText(url, { maxBytes, timeoutMs: 15_000 }));
248
- if ((res.status === 429 || res.status >= 500) && attempt < 2) {
251
+ const res = await this.rawSlots.run(async () => {
252
+ const pause = this.rawPausedUntil - Date.now();
253
+ if (pause > 0)
254
+ await sleep(pause);
255
+ return fetchText(url, { maxBytes, timeoutMs: 15_000 });
256
+ });
257
+ if (res.status === 429 && attempt < 3) {
258
+ // Throttled: every download waits (1, 2, 4 s), not only this one. Retrying after 0.4 s still failed
259
+ // while a repo with hundreds of skills was read (2026-11-03).
260
+ this.rawPausedUntil = Math.max(this.rawPausedUntil, Date.now() + RAW_THROTTLE_WAIT_MS * 2 ** attempt);
261
+ continue;
262
+ }
263
+ if (res.status >= 500 && attempt < 2) {
249
264
  await sleep(400 * (attempt + 1));
250
265
  continue;
251
266
  }
@@ -1,7 +1,7 @@
1
1
  import { RISK_RULES_VERSION, riskLevel } from "./analyze/risk.js";
2
2
  import { skillMdPath } from "./discovery/tree.js";
3
3
  import { PluginUnavailableError, recheckRisk } from "./indexer.js";
4
- import { GRAPHQL_BATCH } from "./sources/github.js";
4
+ import { GitHubError, GRAPHQL_BATCH } from "./sources/github.js";
5
5
  import { syncMarketplace } from "./sources/marketplace.js";
6
6
  import { collectPopularRepos, popularState } from "./sources/popularRepos.js";
7
7
  import { searchActivity } from "./search/activity.js";
@@ -41,18 +41,25 @@ export function startSync(ctx, marketplace, force = false) {
41
41
  .listMarketplaces()
42
42
  .filter((m) => (marketplace ? m.name === marketplace : m.enabled === 1));
43
43
  ctl.setTotal(targets.length);
44
+ const eager = [];
44
45
  for (const m of targets) {
45
46
  await ctl.checkpoint();
46
47
  try {
47
48
  await syncMarketplace(ctx.store, ctx.github, m.name, force);
48
49
  ctl.tick();
50
+ if (m.index_mode === 'eager')
51
+ eager.push(m.name);
49
52
  }
50
53
  catch (err) {
51
54
  ctl.tick(`${m.name}: ${String(err.message ?? err).slice(0, 200)}`);
52
- continue;
53
55
  }
54
- if (m.index_mode === 'eager')
55
- await indexPending(ctx, m.name, ctl);
56
+ }
57
+ // Every list first, then the skills of the marketplace with the fewest plugins to read: Anthropic's own skills
58
+ // (5 plugins) used to wait about 100 s for the 300-plugin official list on a first run (2026-11-03).
59
+ const pending = new Map(eager.map((name) => [name, ctx.store.listPluginsNeedingIndex(name).length]));
60
+ for (const name of eager.sort((a, b) => pending.get(a) - pending.get(b))) {
61
+ await indexPending(ctx, name, ctl);
62
+ ctx.store.clearSearchCache();
56
63
  }
57
64
  ctx.store.clearSearchCache();
58
65
  if (!marketplace && getSettings(ctx.store).autoCollect) {
@@ -68,6 +75,30 @@ const POPULAR_EVERY_MS = 24 * 60 * 60 * 1000;
68
75
  export const REPO_META_EVERY_MS = 6 * 60 * 60 * 1000;
69
76
  const REPO_META_STALE_MS = 18 * 60 * 60 * 1000;
70
77
  let repoMetaRunning = false;
78
+ /** The smallest group asked again in halves; a group this small that still fails waits for the next run. */
79
+ const REPO_META_MIN_SPLIT = 25;
80
+ /** GitHub gave up on the query (502/503/504, usually when it takes too long) or our own 30 s limit ran out. */
81
+ const gaveUp = (err) => (err instanceof GitHubError && [502, 503, 504].includes(err.status)) || err?.name === 'TimeoutError';
82
+ /**
83
+ * One group of repos, asked again in halves when GitHub gives up on it: a hundred lookups in one query can take
84
+ * too long, and one 502 used to end the whole run, leaving the rest stale until the next one 6 hours later
85
+ * (2026-11-03). Other errors (limit reached, network) still end the run.
86
+ */
87
+ async function refreshRepoGroup(ctx, repos) {
88
+ try {
89
+ return { stored: await ctx.github.refreshRepoMeta(repos), left: 0 };
90
+ }
91
+ catch (err) {
92
+ if (!gaveUp(err))
93
+ throw err;
94
+ if (repos.length <= REPO_META_MIN_SPLIT)
95
+ return { stored: 0, left: repos.length, error: err };
96
+ const half = Math.ceil(repos.length / 2);
97
+ const a = await refreshRepoGroup(ctx, repos.slice(0, half));
98
+ const b = await refreshRepoGroup(ctx, repos.slice(half));
99
+ return { stored: a.stored + b.stored, left: a.left + b.left, error: b.error ?? a.error };
100
+ }
101
+ }
71
102
  /**
72
103
  * Keeps star counts of the repos skills come from current (GraphQL, 100 per call), so a search reads them
73
104
  * from the database instead of asking GitHub per repo, and fills repos never fetched (indexed without a
@@ -80,11 +111,20 @@ export async function refreshRepoMeta(ctx) {
80
111
  let stored = 0;
81
112
  try {
82
113
  const repos = ctx.store.listReposToRefresh(new Date(Date.now() - REPO_META_STALE_MS).toISOString(), 10_000);
114
+ let left = 0;
83
115
  for (let i = 0; i < repos.length; i += GRAPHQL_BATCH) {
84
116
  while (searchActivity.busy())
85
117
  await sleep(500);
86
- stored += await ctx.github.refreshRepoMeta(repos.slice(i, i + GRAPHQL_BATCH));
118
+ const group = repos.slice(i, i + GRAPHQL_BATCH);
119
+ const r = await refreshRepoGroup(ctx, group);
120
+ stored += r.stored;
121
+ left += r.left;
122
+ // not one piece answered: GitHub is down rather than slow, so stop asking until the next run
123
+ if (r.left === group.length)
124
+ throw r.error;
87
125
  }
126
+ if (left)
127
+ console.warn('[repo-meta]', `GitHub가 일부 요청에 답하지 않아 저장소 ${left}개는 다음 갱신 때 다시 묻습니다`);
88
128
  }
89
129
  catch (err) {
90
130
  console.warn('[repo-meta]', String(err.message ?? err).slice(0, 200));
@@ -0,0 +1,140 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
+ import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { zstdDecompress } from 'node:zlib';
7
+ export const MODEL_LANGS = ['ko', 'ja'];
8
+ const CDN = 'https://firefox-settings-attachments.cdn.mozilla.net/';
9
+ /**
10
+ * Pinned from Firefox's Remote Settings collection `translations-models-v2` (desktop records, version 3.0), the
11
+ * versions tried with the engine. A newer model may need a newer engine, so they are changed together.
12
+ */
13
+ export const MODEL_FILES = {
14
+ ko: [
15
+ { part: 'lex', name: 'lex.50.50.enko.s2t.bin', location: 'main-workspace/translations-models-v2/493336e3-8e7b-4223-86c5-e88846a85d46.zst', size: 2675843, sha256: '7216367b7f524c0ae1f39353e5c4622b230d1c2e47924182a5853418c1e69f64', unpackedSize: 6676356, unpackedSha256: '7734265822ce315c72334ad538e698ab3900ab8e8f04bd43768d0aecad6b6df7' },
16
+ { part: 'model', name: 'model.enko.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/e4e7e781-bd8e-4dae-9f8e-d0c8a8a5b07b.zst', size: 44942689, sha256: 'ebe3a360c2a007e31df8595bc01a856376c195718ed3c0a27b336eb9b349d313', unpackedSize: 59504955, unpackedSha256: '1c310a79b61b8824b2eb26b045db043d92722f3e66ea06998f7c89f48da9f6bc' },
17
+ { part: 'vocab', name: 'vocab.enko.spm', location: 'main-workspace/translations-models-v2/ac6a46cf-1cf0-4ea0-b1ad-9fa40d6f6513.zst', size: 595440, sha256: 'dc5b8d46f6dff08950bf36db741f52431a41466cf33f5a105d1c5154586559d2', unpackedSize: 1406926, unpackedSha256: '709bf0e425345e0e92ce41ed84348a1296a2107c92bdb6624404450ffecbd1f9' },
18
+ ],
19
+ ja: [
20
+ { part: 'lex', name: 'lex.50.50.enja.s2t.bin', location: 'main-workspace/translations-models-v2/83e22dc9-40c4-4ac1-8c25-5d3877b5f628.zst', size: 1751977, sha256: '8954d5b63432366992b22ad2aaa11895e2643014835049fe677655540a705915', unpackedSize: 4128360, unpackedSha256: 'edfb7eb47b98a2689b804ab3614c31b24f1257aa54b1154da3d85e1ae8152d9f' },
21
+ { part: 'trgvocab', name: 'trgvocab.enja.spm', location: 'main-workspace/translations-models-v2/e54d177c-1131-4fbf-90ea-6741a98d6675.zst', size: 361180, sha256: '5384abf34f0e1298f795bd14909a577272059041e60f91a5eb10500e35d0ee98', unpackedSize: 827144, unpackedSha256: '3b3d9f8f3a034d98d0a476f1794fa79c01e4e98a967ceb6777a66ba2d03ec1e1' },
22
+ { part: 'srcvocab', name: 'srcvocab.enja.spm', location: 'main-workspace/translations-models-v2/d10e4482-b9ba-4565-ab44-5ba3cebcf94b.zst', size: 345747, sha256: 'dd1e93d625d9a8d8a42f75255343f302ea37527933eda1a490652b66a7408a64', unpackedSize: 796275, unpackedSha256: '970c98d174fc01e0339fbabbf45af36a4be3f26f819ec1a5ea1189f71e091889' },
23
+ { part: 'model', name: 'model.enja.intgemm.alphas.bin', location: 'main-workspace/translations-models-v2/a01a5318-41c7-4266-8fe1-66c105d6d711.zst', size: 32431590, sha256: 'f92259b9904812350b71f440c3328f3e2e856f31c1a3ab6002756380dd908e00', unpackedSize: 43849787, unpackedSha256: '59ae659f9bb63e4f81f474fe3c03d3f4499434b5f9e779fab7c12a45f31fd562' },
24
+ ],
25
+ };
26
+ /** The address the page reads the files from (outside /api: the engine's fetch sends no X-Skill-Finder header). */
27
+ export const MODEL_URL_PATH = '/translate-models';
28
+ const FILE_TIMEOUT_MS = 5 * 60 * 1000;
29
+ const sha256 = (b) => createHash('sha256').update(b).digest('hex');
30
+ const pairDir = (lang) => `en${lang}`;
31
+ /** Written last, so a half-finished download is never taken for a model. Its content names the pinned files. */
32
+ const READY = 'ready.json';
33
+ export class TranslateModels {
34
+ dir;
35
+ fetchImpl;
36
+ files;
37
+ running = new Map();
38
+ /** `dir`: where the unpacked files live (`<dataDir>/translate-models`), served as {@link MODEL_URL_PATH}. `files`: tests. */
39
+ constructor(dir, fetchImpl = (...a) => fetch(...a), files = MODEL_FILES) {
40
+ this.dir = dir;
41
+ this.fetchImpl = fetchImpl;
42
+ this.files = files;
43
+ }
44
+ get root() {
45
+ return this.dir;
46
+ }
47
+ readyMark(lang) {
48
+ return JSON.stringify(this.files[lang].map((f) => f.unpackedSha256));
49
+ }
50
+ /** The mark, and every file still there at its size (one deleted by hand or quarantined means download again). */
51
+ ready(lang) {
52
+ try {
53
+ const dir = join(this.dir, pairDir(lang));
54
+ return readFileSync(join(dir, READY), 'utf8') === this.readyMark(lang) && this.files[lang].every((f) => statSync(join(dir, f.name)).size === f.unpackedSize);
55
+ }
56
+ catch {
57
+ return false;
58
+ }
59
+ }
60
+ status(lang) {
61
+ const run = this.running.get(lang);
62
+ if (run && run.state !== 'ready')
63
+ return { ...run };
64
+ const total = this.files[lang].reduce((n, f) => n + f.size, 0);
65
+ return this.ready(lang) ? { lang, state: 'ready', received: total, total, error: null } : { lang, state: 'absent', received: 0, total, error: null };
66
+ }
67
+ /** Starts the download unless it is there or under way; the returned status is polled with {@link status}. */
68
+ start(lang) {
69
+ const now = this.status(lang);
70
+ if (now.state === 'ready' || now.state === 'downloading')
71
+ return now;
72
+ const run = { lang, state: 'downloading', received: 0, total: now.total, error: null };
73
+ this.running.set(lang, run);
74
+ this.download(lang, run).then(() => (run.state = 'ready'), (err) => {
75
+ run.state = 'error';
76
+ run.error = String(err?.message ?? err).slice(0, 300);
77
+ console.warn('[translate]', run.error);
78
+ });
79
+ return { ...run };
80
+ }
81
+ async download(lang, run) {
82
+ const dir = join(this.dir, pairDir(lang));
83
+ await mkdir(dir, { recursive: true });
84
+ await rm(join(dir, READY), { force: true });
85
+ for (const f of this.files[lang]) {
86
+ const target = join(dir, f.name);
87
+ if (existsSync(target) && sha256(readFileSync(target)) === f.unpackedSha256) {
88
+ run.received += f.size;
89
+ continue;
90
+ }
91
+ // network failures and the time limit in the app's words (they used to show as English runtime messages)
92
+ const failed = (err) => new Error(`${f.name}: 내려받지 못했습니다 (${err?.name === 'TimeoutError' ? '시간 초과' : '연결 실패'})`);
93
+ const res = await this.fetchImpl(CDN + f.location, { signal: AbortSignal.timeout(FILE_TIMEOUT_MS) }).catch((err) => {
94
+ throw failed(err);
95
+ });
96
+ if (!res.ok || !res.body)
97
+ throw new Error(`${f.name}: 내려받지 못했습니다 (${res.status})`);
98
+ const chunks = [];
99
+ let size = 0;
100
+ try {
101
+ for await (const chunk of res.body) {
102
+ size += chunk.byteLength;
103
+ if (size > f.size)
104
+ throw new Error(`${f.name}: 크기가 맞지 않습니다`);
105
+ chunks.push(chunk);
106
+ run.received += chunk.byteLength;
107
+ }
108
+ }
109
+ catch (err) {
110
+ throw size > f.size ? err : failed(err);
111
+ }
112
+ const packed = Buffer.concat(chunks);
113
+ if (sha256(packed) !== f.sha256)
114
+ throw new Error(`${f.name}: SHA-256이 맞지 않습니다`);
115
+ const unpacked = await promisify(zstdDecompress)(packed);
116
+ if (unpacked.length !== f.unpackedSize || sha256(unpacked) !== f.unpackedSha256)
117
+ throw new Error(`${f.name}: 압축을 푼 파일이 맞지 않습니다`);
118
+ await writeFile(`${target}.part`, unpacked);
119
+ await rename(`${target}.part`, target);
120
+ }
121
+ await writeFile(join(dir, READY), this.readyMark(lang));
122
+ }
123
+ /**
124
+ * The engine's model registry for the languages that are ready: per pair, its files with their addresses and
125
+ * SHA-256 (the engine checks them again with subresource integrity). The address carries the file's hash, so a
126
+ * browser that kept an older model (cached for good) asks for the new one when the pins change.
127
+ */
128
+ registry() {
129
+ const out = {};
130
+ for (const lang of MODEL_LANGS) {
131
+ if (!this.ready(lang))
132
+ continue;
133
+ out[pairDir(lang)] = Object.fromEntries(this.files[lang].map((f) => [
134
+ f.part,
135
+ { name: `${MODEL_URL_PATH}/${pairDir(lang)}/${f.name}?v=${f.unpackedSha256.slice(0, 16)}`, size: f.unpackedSize, expectedSha256Hash: f.unpackedSha256 },
136
+ ]));
137
+ }
138
+ return out;
139
+ }
140
+ }
@@ -73,6 +73,8 @@ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
73
73
  IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
74
74
  CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
75
75
 
76
+ ## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
77
+
76
78
  ## @phosphor-icons/react - 2.1.10 (MIT)
77
79
 
78
80
  MIT License
@@ -2265,3 +2267,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
2265
2267
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2266
2268
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2267
2269
  SOFTWARE.
2270
+
2271
+ ## @mkljczk/bergamot-translator - 0.4.16 (MPL-2.0)
2272
+
2273
+ The translation engine in bergamot/ is shipped unmodified. Its source (version 0.4.16): https://codeberg.org/mkljczk/bergamot-translator (MPL-2.0, https://mozilla.org/MPL/2.0/). Its WebAssembly is built from bergamot-translator with the libraries it includes, such as Marian NMT (MIT), SentencePiece (Apache-2.0) and intgemm (MIT); their notices are in that source. The translation models are Mozilla's (MPL-2.0), downloaded from Mozilla when turned on.