dsh-m 0.6.1 → 0.8.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.
@@ -29,6 +29,13 @@ function strArray(v) {
29
29
  function numOrNull(v) {
30
30
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
31
31
  }
32
+ /** 非空字符串透传(trim 后),否则 undefined(键不产生)——0.7.0 Task 1 bypass 字段统一口径。 */
33
+ function strOrUndef(v) {
34
+ if (typeof v !== 'string')
35
+ return undefined;
36
+ const s = v.trim();
37
+ return s === '' ? undefined : s;
38
+ }
32
39
  /** npm 形状成立(非空字符串且满足 npm 包名形状)→ source: npm。 */
33
40
  function npmShape(raw) {
34
41
  if (typeof raw.npm !== 'string')
@@ -167,6 +174,15 @@ export function adaptCommunityCatalog(catalog) {
167
174
  capabilityRedLines: strArray(raw.capabilityRedLines),
168
175
  screenshots: strArray(raw.screenshots),
169
176
  descriptionEn: en,
177
+ owner,
178
+ ...(strOrUndef(raw.added) !== undefined ? { added: strOrUndef(raw.added) } : {}),
179
+ ...(raw.deprecated === true ? { deprecated: true } : {}),
180
+ ...(strOrUndef(raw.replacement) !== undefined ? { replacement: strOrUndef(raw.replacement) } : {}),
181
+ ...(strOrUndef(raw.install) !== undefined ? { install: strOrUndef(raw.install) } : {}),
182
+ ...(strOrUndef(raw.downloadsStart) !== undefined ? { downloadsStart: strOrUndef(raw.downloadsStart) } : {}),
183
+ ...(strOrUndef(raw.downloadsEnd) !== undefined ? { downloadsEnd: strOrUndef(raw.downloadsEnd) } : {}),
184
+ ...(strOrUndef(raw.downloadsCheckedAt) !== undefined ? { downloadsCheckedAt: strOrUndef(raw.downloadsCheckedAt) } : {}),
185
+ ...(strOrUndef(raw.version) !== undefined ? { version: strOrUndef(raw.version) } : {}),
170
186
  });
171
187
  }
172
188
  if (fallbackEn > 0)
@@ -19,9 +19,10 @@ export const CATALOG_BODY_TIMEOUT_MS = 15_000;
19
19
  export const COMMUNITY_CHAIN_BUDGET_MS = 30_000;
20
20
  /**
21
21
  * 上游 23 个分类 id 的全量中文标签(以 fixture `categories` 键为准逐条核对)。
22
- * 其中 ui/tools/market 与精选分类同名共享过滤桶、不进筛选栏社区组,标签供详情页等处使用。
22
+ * 0.7.0 Task 4 改名导出 + 作为标签单一事实源:summary 层附带(communitySummary)、
23
+ * GUI zoneChips 消费服务端数据,客户端内嵌副本(market-state.js)随 Task 8 删除。
23
24
  */
24
- export const COMMUNITY_KNOWN_CATEGORIES = {
25
+ export const COMMUNITY_CATEGORY_LABELS = {
25
26
  agi: 'AGI 架构探索',
26
27
  ui: 'UI 增强',
27
28
  usage: '用量与计费',
@@ -227,10 +227,13 @@ export function createApiDispatcher(ctx) {
227
227
  case 'self-check': {
228
228
  try {
229
229
  const latest = await d.npmLatest(ctx.pkg.name, cfg().timeoutMs ?? 20_000);
230
- payload = { current: ctx.pkg.version, latest: latest.version, outdated: isNewerVersion(latest.version, ctx.pkg.version) };
230
+ const outdated = isNewerVersion(latest.version, ctx.pkg.version);
231
+ // ahead(0.8.0):本地 dev 版领先 npm 发布——设置页改显「本地开发版」而非误导性的旧「npm 最新」
232
+ const ahead = !outdated && latest.version !== ctx.pkg.version;
233
+ payload = { current: ctx.pkg.version, latest: latest.version, outdated, ahead };
231
234
  }
232
235
  catch (err) {
233
- payload = { current: ctx.pkg.version, latest: null, outdated: false, error: err instanceof Error ? err.message : String(err) };
236
+ payload = { current: ctx.pkg.version, latest: null, outdated: false, ahead: false, error: err instanceof Error ? err.message : String(err) };
234
237
  }
235
238
  break;
236
239
  }
@@ -261,9 +264,11 @@ export function createApiDispatcher(ctx) {
261
264
  }
262
265
  case 'market': {
263
266
  await ctx.controller.ensureReady();
264
- // GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..50
267
+ // GUI policy:忽略客户端 withLatest,固定 true;limit clamp 1..96、缺省 24
268
+ // (0.7.0 Task 7 探测预算决策:96 为 opt-in 页大小,默认 24 低于 0.6.x 默认 50 的探测负载;
269
+ // 最坏情况被 core 60s deadline 框死为 latestError,不阻塞列表,Q46 不动)
265
270
  const limitRaw = Number(body.limit);
266
- const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(50, Math.max(1, Math.floor(limitRaw))) : 50;
271
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(96, Math.max(1, Math.floor(limitRaw))) : 24;
267
272
  const offsetRaw = Number(body.offset);
268
273
  const offset = Number.isFinite(offsetRaw) && offsetRaw > 0 ? Math.floor(offsetRaw) : 0;
269
274
  // category:精选 5 + 社区开放 slug(非法 slug → 400,不静默吞)
@@ -277,12 +282,33 @@ export function createApiDispatcher(ctx) {
277
282
  throw new ApiProtocolError(400, `非法分类: ${categoryRaw}(需精选分类或 [a-z0-9-]{1,32} slug)`);
278
283
  }
279
284
  }
285
+ // source 分区 + sort(0.7.0 Task 7):非法值 400(不静默吞);primaryOnly 已删除——显式拒绝而非静默忽略(审计 #8)
286
+ if (body.primaryOnly !== undefined) {
287
+ throw new ApiProtocolError(400, '参数 primaryOnly 已删除:改用 source(primary/community/all)');
288
+ }
289
+ const sourceRaw = typeof body.source === 'string' ? body.source.trim() : '';
290
+ if (sourceRaw !== '' && sourceRaw !== 'primary' && sourceRaw !== 'community' && sourceRaw !== 'all') {
291
+ throw new ApiProtocolError(400, `非法 source: ${sourceRaw}(需 primary/community/all)`);
292
+ }
293
+ const source = sourceRaw === '' ? 'all' : sourceRaw;
294
+ let sort;
295
+ if (body.sort !== undefined && body.sort !== null) {
296
+ const s = body.sort;
297
+ if ((s.field === 'downloads' || s.field === 'stars' || s.field === 'added') &&
298
+ (s.dir === 'asc' || s.dir === 'desc')) {
299
+ sort = { field: s.field, dir: s.dir };
300
+ }
301
+ else {
302
+ throw new ApiProtocolError(400, '非法 sort: 需 { field: downloads|stars|added, dir: asc|desc }');
303
+ }
304
+ }
280
305
  const result = await d.listMarket(cfg(), {
281
306
  query: strArg(body, 'query'),
282
307
  category,
308
+ source,
309
+ sort,
283
310
  offset,
284
311
  limit,
285
- primaryOnly: boolArg(body.primaryOnly),
286
312
  force: boolArg(body.force),
287
313
  withLatest: true,
288
314
  namespace: 'host',
@@ -369,6 +395,14 @@ export function createApiDispatcher(ctx) {
369
395
  payload = { applied: true, ...snapshotPayload(snap, cfg()) };
370
396
  break;
371
397
  }
398
+ case 'set-community': {
399
+ // 0.8.0 设置页社区开关:live 生效(内存)+ store 持久化;无需主清单重载
400
+ if (typeof body.enabled !== 'boolean')
401
+ throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
402
+ const snap = await ctx.controller.setCommunity(body.enabled);
403
+ payload = { applied: true, ...snapshotPayload(snap, cfg()) };
404
+ break;
405
+ }
372
406
  case 'registry-default-download': {
373
407
  const loaded = await ctx.controller.loadDefault({ force: true, signal });
374
408
  payload = { registry: loaded.registry, registryState: loaded };
@@ -18,8 +18,9 @@ import { precheckNpmCompat, IncompatibleError } from './compat-check.js';
18
18
  import { PROTECTED_MODULES, composeEnablement } from './enablement.js';
19
19
  import { readProfileOverrides } from './patch-yaml.js';
20
20
  import { loadRegistry as defaultLoadRegistry, } from './registry.js';
21
- import { fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
21
+ import { COMMUNITY_CATEGORY_LABELS, fetchCommunityCatalog as defaultFetchCommunityCatalog, } from './community.js';
22
22
  import { adaptCommunityCatalog } from './community-adapter.js';
23
+ import { normalizeSearchText, relevanceScore, tokenizeSearchText } from './search-relevance.js';
23
24
  import { GithubBudgetExhaustedError, createGithubRequestBudget, isExactVersion } from './versions.js';
24
25
  import { HttpError } from './httpx.js';
25
26
  import { verifyInstalledAdditions } from './install-guard.js';
@@ -59,7 +60,10 @@ function loaderEntriesOrNull() {
59
60
  }
60
61
  // ---------- 通用工具 ----------
61
62
  const DEFAULT_DEADLINE_MS = 60_000;
62
- const WITH_LATEST_MAX = 50;
63
+ /** withLatest 上限 = 96(0.7.0 Task 7:50→96,opt-in 页大小;默认页 24 的探测负载低于 0.6.x 默认 50——
64
+ * 96/页冷缓存最坏情况被 60s deadline 框死为 latestError 不阻塞列表,Q46「探测对象=页面条目」不动;
65
+ * 人工验收阈值:96/页冷缓存 latestError > 20% 即回退默认页大小并重议)。 */
66
+ const WITH_LATEST_MAX = 96;
63
67
  const METADATA_ONLY_MAX = 80;
64
68
  const LATEST_WORKERS = 8;
65
69
  export function abortError() {
@@ -256,7 +260,7 @@ export function mergeRegistries(primary, community) {
256
260
  const warnings = displaced.length > 0 ? [`${displaced.length} 条社区条目与主清单重复,已让位(主清单恒优先)`] : [];
257
261
  return { items: [...primary, ...sorted], displaced: displaced.length, warnings };
258
262
  }
259
- function communitySummary(state, counts, extraWarnings) {
263
+ function communitySummary(state, counts, extraWarnings, categoryLabelsEn) {
260
264
  const unavailableLike = state.status === 'disabled' || state.status === 'unavailable';
261
265
  return {
262
266
  enabled: state.enabled,
@@ -272,23 +276,38 @@ function communitySummary(state, counts, extraWarnings) {
272
276
  skippedSubpathNoNpm: unavailableLike ? 0 : (counts.skippedSubpathNoNpm ?? 0),
273
277
  errors: [...state.errors],
274
278
  warnings: [...state.warnings, ...extraWarnings],
279
+ // 标签单一事实源(0.7.0 Task 4):disabled/skipped 下无社区数据语义,不携带;
280
+ // EN 标签(i18n)随行携带,仅在调用方传入非空映射时出现(缺省 = 上游无 categories 数据)
281
+ ...(state.status !== 'disabled' && state.status !== 'skipped'
282
+ ? {
283
+ categoryLabels: { ...COMMUNITY_CATEGORY_LABELS },
284
+ ...(categoryLabelsEn && Object.keys(categoryLabelsEn).length > 0
285
+ ? { categoryLabelsEn: { ...categoryLabelsEn } }
286
+ : {}),
287
+ }
288
+ : {}),
275
289
  };
276
290
  }
277
291
  function disabledCommunitySummary() {
278
292
  return communitySummary({ enabled: false, status: 'disabled', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
279
293
  }
294
+ /** 查询层主动跳过(0.7.0 Task 2:source='primary'):本次未加载社区层,非配置关闭、非故障。 */
295
+ function skippedCommunitySummary() {
296
+ return communitySummary({ enabled: true, status: 'skipped', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: [], warnings: [] }, {}, []);
297
+ }
280
298
  function communityTimeoutSummary() {
281
299
  return communitySummary({ enabled: true, status: 'unavailable', version: null, checkedAt: null, fetchedAt: null, route: null, count: 0, errors: ['社区目录状态获取超时,可稍后刷新'], warnings: [] }, {}, []);
282
300
  }
283
301
  /**
284
302
  * 社区 loader waiter 收敛(v9/v10 waiter-scoped 契约):共享 flight 不接收调用者 deadline,
285
303
  * 本函数作为 waiter 用剩余 deadline race 自己的等待;到点只结束本 waiter(summary 标超时),
286
- * 共享 flight 照常继续。primaryOnly/未启用 → loader 零调用(task 传 null)。
304
+ * 共享 flight 照常继续。source='primary'/未启用 → loader 零调用(task 传 null → skipped;
305
+ * 配置关闭走真任务的 disabled 分支,与跳过语义分离)。
287
306
  * 导出供 community.ts getCommunitySummary 复用(summary 组装单一产地)。
288
307
  */
289
308
  export async function communityOutcome(task, deadlineAt, primary) {
290
309
  if (!task)
291
- return { summary: disabledCommunitySummary(), merged: primary };
310
+ return { summary: skippedCommunitySummary(), merged: primary };
292
311
  let loaded;
293
312
  try {
294
313
  loaded = await deadlineRace(task, deadlineAt - Date.now());
@@ -310,18 +329,65 @@ export async function communityOutcome(task, deadlineAt, primary) {
310
329
  }
311
330
  const adapted = adaptCommunityCatalog(loaded.catalog);
312
331
  const merge = mergeRegistries(primary, adapted.entries);
332
+ // EN 分类标签(i18n):直接取上游目录 categories.en——上游新增分类自动跟进,
333
+ // 缺 en 的 id 不进映射(客户端按语言取用并回退中文标签,不在此处手养第二张表)
334
+ const categoryLabelsEn = Object.fromEntries(Object.entries(loaded.catalog.categories)
335
+ .map(([id, c]) => [id, typeof c?.en === 'string' && c.en !== '' ? c.en : ''])
336
+ .filter(([, en]) => en !== ''));
313
337
  const summary = communitySummary(state, {
314
338
  acceptedCount: adapted.entries.length,
315
339
  upstreamCount: loaded.catalog.plugins.length,
316
340
  displaced: merge.displaced,
317
341
  skippedDirty: adapted.skippedDirty,
318
342
  skippedSubpathNoNpm: adapted.skippedSubpathNoNpm,
319
- }, [...adapted.warnings, ...merge.warnings]);
343
+ }, [...adapted.warnings, ...merge.warnings], categoryLabelsEn);
320
344
  return { summary, merged: merge.items };
321
345
  }
322
346
  function isCommunityEntry(entry) {
323
347
  return entry.descriptionEn !== undefined;
324
348
  }
349
+ /** 旁路数值/日期读取(RegistryEntry 无这些键 → null/'';0.7.0 Task 2 排序用)。 */
350
+ function dlOf(e) {
351
+ const v = e.downloads;
352
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
353
+ }
354
+ function starsOf(e) {
355
+ const v = e.stars;
356
+ return typeof v === 'number' && Number.isFinite(v) ? v : -1;
357
+ }
358
+ function addedOf(e) {
359
+ const v = e.added;
360
+ return typeof v === 'string' ? v : '';
361
+ }
362
+ /**
363
+ * 分区排序(0.7.0 Task 2):`dir` 只翻转有数据的比较,**不翻转「无数据垫底」语义**——
364
+ * downloads 无计数 ≠ 0(无数据者恒排有数据者之后,组内按 stars 降序再 name);
365
+ * stars 缺失视为 -1、added 缺失视为最旧(空串日期),两者参与正常比较随 dir 翻转。
366
+ */
367
+ function sortEntries(entries, sort) {
368
+ const dirMul = sort.dir === 'asc' ? 1 : -1;
369
+ const nameCmp = (a, b) => String(a.name).localeCompare(String(b.name));
370
+ return entries.slice().sort((a, b) => {
371
+ if (sort.field === 'downloads') {
372
+ const da = dlOf(a);
373
+ const db = dlOf(b);
374
+ if (da !== null && db !== null)
375
+ return (da - db) * dirMul || nameCmp(a, b);
376
+ if (da !== null)
377
+ return -1;
378
+ if (db !== null)
379
+ return 1;
380
+ return starsOf(b) - starsOf(a) || nameCmp(a, b);
381
+ }
382
+ if (sort.field === 'stars') {
383
+ const diff = starsOf(a) - starsOf(b);
384
+ return diff * dirMul || nameCmp(a, b);
385
+ }
386
+ const aa = addedOf(a);
387
+ const ab = addedOf(b);
388
+ return (aa < ab ? -1 : aa > ab ? 1 : 0) * dirMul || nameCmp(a, b);
389
+ });
390
+ }
325
391
  /** 合并条目 → MarketItem:社区条目带 community 标记与旁路字段。 */
326
392
  function toMarketItem(entry, installedItems) {
327
393
  const inst = matchInstalledByEntry(entry, installedItems);
@@ -337,14 +403,26 @@ function toMarketItem(entry, installedItems) {
337
403
  item.capabilities = entry.capabilities;
338
404
  item.capabilityRedLines = entry.capabilityRedLines;
339
405
  item.screenshots = entry.screenshots;
406
+ item.owner = entry.owner;
407
+ if (entry.added !== undefined)
408
+ item.added = entry.added;
409
+ if (entry.deprecated !== undefined)
410
+ item.deprecated = entry.deprecated;
411
+ if (entry.replacement !== undefined)
412
+ item.replacement = entry.replacement;
413
+ if (entry.install !== undefined)
414
+ item.install = entry.install;
415
+ if (entry.downloadsStart !== undefined)
416
+ item.downloadsStart = entry.downloadsStart;
417
+ if (entry.downloadsEnd !== undefined)
418
+ item.downloadsEnd = entry.downloadsEnd;
419
+ if (entry.downloadsCheckedAt !== undefined)
420
+ item.downloadsCheckedAt = entry.downloadsCheckedAt;
421
+ if (entry.version !== undefined)
422
+ item.version = entry.version;
340
423
  }
341
424
  return item;
342
425
  }
343
- /** 合并条目的搜索串:社区条目附英文描述原文(Q45 搜索同时匹配中英文)。 */
344
- function searchableText(entry) {
345
- const base = `${entry.id} ${entry.name} ${entry.description} ${entry.tags.join(' ')}`;
346
- return isCommunityEntry(entry) ? `${base} ${entry.descriptionEn}` : base;
347
- }
348
426
  // ---------- 市场列表 ----------
349
427
  function marketDeps() {
350
428
  return {
@@ -363,15 +441,17 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
363
441
  : DEFAULT_DEADLINE_MS;
364
442
  const deadlineAt = startedAt + deadlineMs;
365
443
  const namespace = opts.namespace ?? 'host';
444
+ // 分区归一(0.7.0 Task 2):非法值一律按 'all'
445
+ const source = opts.source === 'primary' || opts.source === 'community' ? opts.source : 'all';
366
446
  const withLatest = opts.withLatest !== false;
367
447
  const maxLimit = withLatest ? WITH_LATEST_MAX : METADATA_ONLY_MAX;
368
448
  const signal = opts.signal;
369
449
  const remaining = () => deadlineAt - Date.now();
370
450
  const registryTask = d.loadRegistry(cfg, { namespace, signal, force: opts.force, deadlineMs });
371
451
  const installedTask = d.listInstalledPlugins().catch(() => null);
372
- // 社区 flight 并发启动(primaryOnly 零调用);共享 loader 不接收调用者 deadline——
452
+ // 社区 flight 并发启动(source=primary 零调用;0.7.0 Task 7:primaryOnly 字段已删除);共享 loader 不接收调用者 deadline——
373
453
  // listMarket 作为 waiter 在 communityOutcome 内 race 自己的剩余 deadline/signal(v10 契约)
374
- const communityTask = opts.primaryOnly === true ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
454
+ const communityTask = source === 'primary' ? null : d.fetchCommunityCatalog(cfg, { namespace, signal, force: opts.force });
375
455
  let loaded;
376
456
  try {
377
457
  loaded = await deadlineRace(registryTask, remaining());
@@ -403,26 +483,43 @@ export async function listMarket(cfg = {}, opts = {}, deps = {}) {
403
483
  const installedItems = installed?.items ?? [];
404
484
  // 社区 waiter 收敛:主 unavailable 时 merged = 存活社区条目(Q42 出页不返空);两层皆不可用 → 空页契约
405
485
  const community = await communityOutcome(communityTask, deadlineAt, loaded.registry.plugins);
406
- // 全量统计 + query/category 过滤 + 分页(同步,极轻)
407
- const all = community.merged;
486
+ // 分区过滤 + 全量统计 + query/category 过滤 + 分页(同步,极轻;0.7.0 Task 2):
487
+ // categoryCounts = 分区集合(不含 query/category 过滤——chips 需要全区计数);
488
+ // total = 分区 ∩ query ∩ category。
489
+ const merged = community.merged;
490
+ const zoned = source === 'community'
491
+ ? merged.filter(isCommunityEntry)
492
+ : source === 'primary'
493
+ ? merged.filter((entry) => !isCommunityEntry(entry))
494
+ : merged;
408
495
  const counts = zeroCounts();
409
- for (const entry of all)
496
+ for (const entry of zoned)
410
497
  counts[entry.category] = (counts[entry.category] ?? 0) + 1;
411
- const q = (opts.query ?? '').trim().toLowerCase();
412
498
  const cat = opts.category ?? null;
413
- const filtered = all.filter((entry) => {
499
+ // 相关性搜索(0.7.0 Task 3):归一化分词 + 字段加权评分;0 分不返回;
500
+ // id 整串精确匹配保证命中(收藏 stale 检测依赖)。
501
+ const terms = tokenizeSearchText(normalizeSearchText(opts.query ?? ''));
502
+ const filtered = zoned.filter((entry) => {
414
503
  if (cat && entry.category !== cat)
415
504
  return false;
416
- if (!q)
505
+ if (terms.length === 0)
417
506
  return true;
418
- return searchableText(entry).toLowerCase().includes(q);
507
+ return relevanceScore(entry, terms) > 0;
419
508
  });
420
- const total = filtered.length;
509
+ // 排序:显式 sort 先行;query 命中时相关性优先(稳定 tie-break 回到既有序——merged 现序或用户排序)
510
+ const afterSort = opts.sort ? sortEntries(filtered, opts.sort) : filtered;
511
+ const ordered = terms.length > 0
512
+ ? afterSort
513
+ .map((entry, idx) => ({ entry, score: relevanceScore(entry, terms), idx }))
514
+ .sort((a, b) => b.score - a.score || a.idx - b.idx)
515
+ .map((s) => s.entry)
516
+ : afterSort;
517
+ const total = ordered.length;
421
518
  const limit = clampLimit(opts.limit, maxLimit);
422
519
  let offset = normalizeOffset(opts.offset);
423
520
  if (total > 0 && offset >= total)
424
521
  offset = Math.floor((total - 1) / limit) * limit;
425
- const items = filtered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
522
+ const items = ordered.slice(offset, offset + limit).map((entry) => toMarketItem(entry, installedItems));
426
523
  let latestComplete = true;
427
524
  let latestTimedOut = false;
428
525
  if (withLatest && items.length > 0) {
@@ -617,13 +714,17 @@ async function probeLatest(items, ctx, d, rt) {
617
714
  item.outdated = !item.spec.includes(item.latestSha);
618
715
  });
619
716
  }
620
- /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。 */
717
+ /** 从 registry 收录条目安装(npm → 精确锁定最新版;github → 锁 HEAD SHA)。
718
+ * 0.7.1 修复:合并市场展示的社区条目 id 不在主清单(loadRegistry 只装载主清单,
719
+ * 社区层只合入 listMarket 展示)——主清单 miss 时按收录 id 查社区目录再装,
720
+ * 与 0.5.1 升级路径(findCommunityUpgradeEntry)同构。 */
621
721
  export async function installFromRegistry(id, cfg = {}, opts = {}, deps) {
622
722
  const loaded = await (deps?.loadRegistry ?? defaultLoadRegistry)(cfg, { namespace: opts.namespace ?? 'host' });
623
723
  if (loaded.status === 'unavailable') {
624
724
  throw new Error(`收录清单不可用,无法安装 ${id};请检查 registry 配置或网络后重试`);
625
725
  }
626
- const entry = loaded.registry.plugins.find((e) => e.id === id);
726
+ const entry = loaded.registry.plugins.find((e) => e.id === id) ??
727
+ (await findCommunityInstallEntry(id, cfg, opts, deps));
627
728
  if (!entry)
628
729
  throw new Error(`registry 中没有该条目: ${id}`);
629
730
  return installEntry(entry, cfg, opts, deps);
@@ -1185,6 +1286,22 @@ async function upgradePluginLocked(pkg, cfg = {}, opts = {}, deps) {
1185
1286
  const result = await installEntryLocked(entry, cfg, opts, deps);
1186
1287
  return { ...result, fromVersion: target.version };
1187
1288
  }
1289
+ /**
1290
+ * 社区条目按收录 id 查找(0.7.1 安装修复):合并市场展示的社区条目 id = 社区目录合成 id
1291
+ * (owner--name 等),主清单 miss 时按同一 id 查社区目录。社区清单未启用/不可用/加载
1292
+ * 异常/未命中 → undefined,由调用方统一报「registry 中没有该条目」。
1293
+ */
1294
+ async function findCommunityInstallEntry(id, cfg, opts, deps) {
1295
+ try {
1296
+ const loaded = await (deps?.fetchCommunityCatalog ?? defaultFetchCommunityCatalog)(cfg, { namespace: opts.namespace ?? 'host', signal: opts.signal });
1297
+ if (!loaded.catalog)
1298
+ return undefined;
1299
+ return adaptCommunityCatalog(loaded.catalog).entries.find((e) => e.id === id);
1300
+ }
1301
+ catch {
1302
+ return undefined;
1303
+ }
1304
+ }
1188
1305
  /**
1189
1306
  * 社区条目升级查找(0.5.1):主清单 miss 时按同一 matchInstalledByEntry 语义查社区目录。
1190
1307
  * 社区清单未启用/不可用/加载异常/未命中 → undefined,由调用方统一报「不是经 dsh-m 收录」。
@@ -310,6 +310,32 @@ export function createRegistryController(initial = {}) {
310
310
  return snapshotInternal();
311
311
  });
312
312
  }
313
+ function setCommunity(enabled) {
314
+ // 同步先启动 bootstrap(只创建不等待):它先于本任务入队,任务内再 await 即安全——
315
+ // 若在任务内 await startBootstrap() 会自我死锁(bootstrap 排在本任务之后永远轮不到)
316
+ if (!bootstrapPromise)
317
+ startBootstrap();
318
+ return enqueue(async (gen) => {
319
+ // 等初始同步完成:bootstrap 的 syncCommunityFields 会按 source 重写社区字段,
320
+ // 开关若抢在它前面写入会被覆盖(时序隐患,0.7.9 测试暴露于 0.8.0)
321
+ await bootstrapPromise;
322
+ const prev = config.communityCatalog;
323
+ // 内存先行(live 生效:listMarket 立即跳过社区层);持久化失败回滚内存值
324
+ config.communityCatalog = enabled;
325
+ if (store) {
326
+ try {
327
+ await store.update({ communityCatalog: enabled });
328
+ }
329
+ catch (err) {
330
+ if (disposed || gen !== generation)
331
+ return snapshotInternal();
332
+ config.communityCatalog = prev;
333
+ throw new RegistryConfigError(`写入设置失败:${errorMessage(err)}`, [errorMessage(err)]);
334
+ }
335
+ }
336
+ return snapshotInternal();
337
+ });
338
+ }
313
339
  function dispose() {
314
340
  disposed = true;
315
341
  unwatch?.();
@@ -323,6 +349,7 @@ export function createRegistryController(initial = {}) {
323
349
  snapshot,
324
350
  loadDefault,
325
351
  apply,
352
+ setCommunity,
326
353
  dispose,
327
354
  };
328
355
  return controller;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 搜索相关性管线(0.7.0 Task 3 / DESIGN.md §2.6「搜索:相关性加权管线」):
3
+ * NFKC 归一化 + 中西文边界插空格 + 标点归空格 → 分词 → 字段加权评分。
4
+ *
5
+ * - id 为单列最高优先:normalized id 与整条 query 精确相等 → ID_EXACT_SCORE(收藏 stale 检测
6
+ * 依赖此语义,豁免「多词同字段全命中」约束);
7
+ * - 字段权重:name/npm 700 · owner 400 · 首选描述 280 · 次选描述 240 · 分类 180 · tags 150
8
+ * (tags 为 dsh-m 主清单召回保留——dsh-market 目录无 tags 概念,主清单 tags 有文案标准);
9
+ * - 命中类型加分:精确 +300 / 前缀 +250 / 包含 +200 / 多词全命中 +150;
10
+ * 多词要求**同字段全命中**,否则该字段 0 分;
11
+ * - 按条目 WeakMap memoize 归一化字段串——4,400+ 条 × 每次搜索请求全量归一化的性能护栏
12
+ * (条目对象随每次 adapt 重建,缓存随条目代际自然失效,无陈旧风险)。
13
+ */
14
+ /** id 整串精确匹配得分:恒高于一切字段组合分,保证排首且必命中。 */
15
+ export const ID_EXACT_SCORE = 10_000;
16
+ const W_NAME = 700;
17
+ const W_OWNER = 400;
18
+ const W_DESC = 280;
19
+ const W_DESC_EN = 240;
20
+ const W_CATEGORY = 180;
21
+ const W_TAGS = 150;
22
+ const B_EXACT = 300;
23
+ const B_PREFIX = 250;
24
+ const B_CONTAINS = 200;
25
+ const B_MULTI = 150;
26
+ const CJK_RE = /[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff]/;
27
+ const WORD_RE = /[\p{L}\p{N}]/u;
28
+ /** 归一化:NFKC + 小写 + 中西文边界插空格(`MCP管理` ≈ `MCP 管理`)+ 标点/符号/空白归单空格。 */
29
+ export function normalizeSearchText(input) {
30
+ const nfk = input.normalize('NFKC').toLowerCase();
31
+ let out = '';
32
+ for (let i = 0; i < nfk.length; i++) {
33
+ const ch = nfk[i];
34
+ if (i > 0) {
35
+ const prev = nfk[i - 1];
36
+ if (WORD_RE.test(ch) && WORD_RE.test(prev) && CJK_RE.test(ch) !== CJK_RE.test(prev))
37
+ out += ' ';
38
+ }
39
+ out += ch;
40
+ }
41
+ return out.replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
42
+ }
43
+ /** 分词(输入应为 normalizeSearchText 产物)。 */
44
+ export function tokenizeSearchText(normalized) {
45
+ return normalized.split(/\s+/).filter((t) => t !== '');
46
+ }
47
+ /** 多段字段(如 tags)归一化为单空格连接的 token 串。 */
48
+ function fieldText(parts) {
49
+ return parts
50
+ .filter((p) => typeof p === 'string' && p.trim() !== '')
51
+ .map((p) => normalizeSearchText(p))
52
+ .filter((p) => p !== '')
53
+ .join(' ');
54
+ }
55
+ const fieldsCache = new WeakMap();
56
+ function fieldsOf(entry) {
57
+ const cached = fieldsCache.get(entry);
58
+ if (cached)
59
+ return cached;
60
+ const fields = [
61
+ { text: fieldText([entry.name]), weight: W_NAME },
62
+ ...(entry.npm !== undefined ? [{ text: fieldText([entry.npm]), weight: W_NAME }] : []),
63
+ ...(entry.owner !== undefined ? [{ text: fieldText([entry.owner]), weight: W_OWNER }] : []),
64
+ { text: fieldText([entry.description]), weight: W_DESC },
65
+ ...(entry.descriptionEn !== undefined ? [{ text: fieldText([entry.descriptionEn]), weight: W_DESC_EN }] : []),
66
+ { text: fieldText([entry.category]), weight: W_CATEGORY },
67
+ ...(entry.tags !== undefined && entry.tags.length > 0 ? [{ text: fieldText(entry.tags), weight: W_TAGS }] : []),
68
+ ];
69
+ const value = { id: fieldText([entry.id]), fields };
70
+ fieldsCache.set(entry, value);
71
+ return value;
72
+ }
73
+ function hitBonus(field, term) {
74
+ if (field === term)
75
+ return B_EXACT;
76
+ if (field.startsWith(term))
77
+ return B_PREFIX;
78
+ if (field.includes(term))
79
+ return B_CONTAINS;
80
+ return 0;
81
+ }
82
+ /** 单字段评分:全部词命中才得分(取最弱命中类型 + 多词加成);任一词未命中 → 0。 */
83
+ function scoreField(text, weight, terms) {
84
+ if (text === '')
85
+ return 0;
86
+ let min = Number.POSITIVE_INFINITY;
87
+ for (const term of terms) {
88
+ const b = hitBonus(text, term);
89
+ if (b === 0)
90
+ return 0;
91
+ if (b < min)
92
+ min = b;
93
+ }
94
+ return weight + min + (terms.length > 1 ? B_MULTI : 0);
95
+ }
96
+ /** 相关性评分(>0 = 命中):id 整串精确匹配最高优先且豁免多词约束;否则取各字段最大分。 */
97
+ export function relevanceScore(entry, terms) {
98
+ if (terms.length === 0)
99
+ return 0;
100
+ const f = fieldsOf(entry);
101
+ if (f.id !== '' && f.id === terms.join(' '))
102
+ return ID_EXACT_SCORE;
103
+ let best = 0;
104
+ for (const field of f.fields) {
105
+ const s = scoreField(field.text, field.weight, terms);
106
+ if (s > best)
107
+ best = s;
108
+ }
109
+ return best;
110
+ }