@stofone/dsh-agent-roster 0.10.7 → 0.11.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.11.0] - 2026-11-03
8
+
9
+ ### Changed
10
+
11
+ - **Expanding a collection no longer walks its rosters one by one.** `fetchCollection` runs up to six
12
+ fetches at a time and keeps the index order for both rosters and diagnostics: the 21-roster
13
+ `agent-roster-collective` went from **9.4 s to 0.8 s** on this machine (serial vs parallel, real network).
14
+ - The collection route memoises the fetched result for 60 s, and the panel keeps the last expansion in
15
+ memory — re-opening a collection paints immediately instead of re-fetching 21 files.
16
+
17
+ ### Added
18
+
19
+ - A filter box inside an expanded collection: narrows by roster id, title or member name, and shows
20
+ `shown / total`. It only hides rows; it never writes anything.
21
+
7
22
  ## [0.10.7] - 2026-11-03
8
23
 
9
24
  ### Documentation
@@ -533,7 +548,8 @@
533
548
  - 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
534
549
  且所有返回值先过 `lib/json.js#lossless`。
535
550
 
536
- [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.7...main
551
+ [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.0...main
552
+ [0.11.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.7...v0.11.0
537
553
  [0.10.7]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.6...v0.10.7
538
554
  [0.10.6]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.5...v0.10.6
539
555
  [0.10.5]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.4...v0.10.5
package/README.md CHANGED
@@ -398,6 +398,12 @@ repository's `index/registry.json`, and anyone who subscribes can see them in th
398
398
  - **Pulling**: writes are idempotent and report one of `created`, `updated` (explicit overwrite), `unchanged`
399
399
  (same content, nothing written) or `conflict` (the local copy differs and is **not** overwritten by default).
400
400
  Conflicts are yours to resolve: merge in the editor, or pull again with `overwrite` in the request body.
401
+ - **Expanding is fast and filterable**: opening a collection fetches its rosters in parallel (up to six in
402
+ flight) instead of one after another — the 21-roster collective went from **9.4 s to 0.8 s** on this
403
+ machine. The route memoises the fetched result for 60 seconds, and the panel keeps the last expansion in
404
+ memory, so re-opening is instant and re-opening after a minute costs one round of six requests, not 21.
405
+ A filter box inside the expanded list narrows by id, title or member name; it only hides rows, it never
406
+ changes data.
401
407
  - **Where each roster lives**: a collection's `rosters[]` is just the list of ids; the optional
402
408
  `files` map says where each one is (`{"core-review": "dist/core-review-1.0.0.json"}`). It is optional —
403
409
  without it the fetcher assumes the v1 layout `<id>/roster.yaml` — but a v2 library needs it, because an
@@ -486,7 +492,7 @@ test/ unit tests
486
492
 
487
493
  Verified on this machine:
488
494
 
489
- - `npm test` — 297/297 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, library organization, registry schema, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
495
+ - `npm test` — 300/300 passing (format, parse, validate, registry, paths, bridge, verify buckets, digests and provenance, roster diff, capability requirements and the capability gate, library organization, registry schema, lossless JSON, write plan/apply, all routes, panel snapshot and hints, dictionary consistency, tool contracts, client loader contract, library snapshot, config view, package manifest, declared fields and captain block, schema/constant alignment, the v2 role/roster/compiled layers and their schema alignment, v1→v2 migration, run records feeding the plan, remote registry and one-click install).
490
496
  - `npm run smoke` — `apply()` registers all six tools through a real `defineTool` from a DSH bundle; `roster_list` / `roster_show` / `roster_create` / `roster_verify` / `roster_diff` execute, every output survives `lossless` checks, `path` overrides are honored, effect disposal runs cleanly. The same command then loads `client.js` against a stubbed module table and asserts the three slot registrations, style injection and effect disposal.
491
497
  - CLI: `validate --dir examples` returns exit 0 over both example libraries.
492
498
  - Installed into a real profile with the plugin manager (`plugin_manager install_bundle` against this directory → `link:` dependency + bundle entry); the profile re-reads the linked files on restart.
package/README.zh.md CHANGED
@@ -371,6 +371,10 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
371
371
  可绕过缓存,但 60 秒内最多一次,避免把配额烧光。拉取失败时沿用上次缓存并在页面上写明「缓存(拉取失败)」。
372
372
  缓存里记着**它存的是哪一种形状的索引**(`CACHE_VERSION`):升级插件后如果索引形状变了,旧缓存自动失效、重取一次,
373
373
  所以「我升级了,面板却还 404」不需要靠删目录来排查。
374
+ - **展开很快、也能查找**:展开一个集合时档案是**并发**拉的(最多 6 条在飞),不是一份一份排队——
375
+ 21 份的集合在本机从 **9.4 秒降到 0.8 秒**。服务端把结果记 60 秒,面板也记住上次展开的内容,
376
+ 所以再点开是瞬间的,隔一分钟再点开也只花一轮 6 个请求而不是 21 个。展开区里带一个查找框,
377
+ 按 id、标题或成员名过滤;它只隐藏行,不改任何数据。
374
378
  - **拉取到本机**:写入是幂等的,四种结果都会报给你——
375
379
  `created`(新写入)、`updated`(显式覆盖)、`unchanged`(内容一致,不写盘)、`conflict`(本地不一样,**默认不覆盖**)。
376
380
  冲突要你自己决定:要么在编辑器里合并,要么再点一次「拉取到本机」并在请求里带 `overwrite`。
@@ -459,7 +463,7 @@ test/ 单测
459
463
 
460
464
  本机已验证:
461
465
 
462
- - `npm test` —— 297/297 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
466
+ - `npm test` —— 300/300 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
463
467
  - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
464
468
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
465
469
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
package/client.js CHANGED
@@ -1663,11 +1663,27 @@ window.__ModuleLoader__.load({
1663
1663
  * 拉取是**显式动作**:后端按内容比对,相同的跳过、不同的默认不覆盖(会把冲突数报回来)。
1664
1664
  * 前端不做这份判断——它只把结果说清楚,避免"点一下按钮,本地改动就没了"。
1665
1665
  */
1666
+ // 展开过的 collection 记在模块里:再点开先画缓存,再在后台刷新。
1667
+ // 服务端也有一份 60s 缓存,两层各管一段——这层管"切换页签不白等",那层管"重复展开不打网络"。
1668
+ const SOURCE_DETAIL_TTL_MS = 60_000;
1669
+ const sourceDetailCache = new Map();
1670
+
1671
+ function cachedSourceDetail(key) {
1672
+ const entry = sourceDetailCache.get(key);
1673
+ if (entry === undefined) return null;
1674
+ if (Date.now() - entry.at >= SOURCE_DETAIL_TTL_MS) {
1675
+ sourceDetailCache.delete(key);
1676
+ return null;
1677
+ }
1678
+ return entry.body;
1679
+ }
1680
+
1666
1681
  function SourcesTab({ remote, onToast, onPulled, onRefresh, refreshing }) {
1667
1682
  useTranslation();
1668
1683
  const [pulling, setPulling] = useState("");
1669
1684
  const [openId, setOpenId] = useState("");
1670
1685
  const [detail, setDetail] = useState(null);
1686
+ const [filter, setFilter] = useState("");
1671
1687
 
1672
1688
  const collections = remote?.collections ?? [];
1673
1689
  const indexes = remote?.indexes ?? [];
@@ -1700,24 +1716,77 @@ window.__ModuleLoader__.load({
1700
1716
  }
1701
1717
  }
1702
1718
 
1719
+ function detailKey(collection) {
1720
+ return `${collection.origin?.source ?? ""}::${collection.id}`;
1721
+ }
1722
+
1703
1723
  async function toggle(collection) {
1704
1724
  if (openId === collection.id) {
1705
1725
  setOpenId("");
1706
1726
  setDetail(null);
1707
1727
  return;
1708
1728
  }
1729
+ const key = detailKey(collection);
1730
+ const remembered = cachedSourceDetail(key);
1709
1731
  setOpenId(collection.id);
1710
- setDetail({ id: collection.id, status: "loading", body: null });
1732
+ setFilter("");
1733
+ // 有缓存就先出内容,用户看到的"等待"只剩网络那一半。
1734
+ setDetail(remembered === null ? { id: collection.id, status: "loading", body: null } : { id: collection.id, status: "ready", body: remembered });
1711
1735
  const query = new URLSearchParams({ id: collection.id });
1712
1736
  if (collection.origin?.source !== undefined) query.set("source", collection.origin.source);
1713
1737
  const result = await readJson(`${COLLECTION_ROUTE}?${query.toString()}`);
1714
1738
  if (result.ok && result.body !== null) {
1739
+ sourceDetailCache.set(key, { at: Date.now(), body: result.body });
1715
1740
  setDetail({ id: collection.id, status: "ready", body: result.body });
1716
- } else {
1741
+ } else if (remembered === null) {
1717
1742
  setDetail({ id: collection.id, status: "error", body: null });
1718
1743
  }
1719
1744
  }
1720
1745
 
1746
+ /**
1747
+ * 展开区:先把「查什么」和「查到几条」放出来,再列档案。
1748
+ * 查找只过滤视图,不改任何数据,也不触发网络。
1749
+ */
1750
+ function renderDetail() {
1751
+ const all = detail?.body?.rosters ?? [];
1752
+ const needle = filter.trim().toLowerCase();
1753
+ const shown =
1754
+ needle === ""
1755
+ ? all
1756
+ : all.filter((roster) =>
1757
+ [roster.id, roster.title, roster.description, ...(roster.members ?? []).map((member) => member.name)]
1758
+ .filter((value) => typeof value === "string")
1759
+ .some((value) => value.toLowerCase().includes(needle)),
1760
+ );
1761
+ return h(
1762
+ "div",
1763
+ { className: "rstr-source-detail" },
1764
+ h(
1765
+ "div",
1766
+ { className: "rstr-source-filter" },
1767
+ h("input", {
1768
+ className: "rstr-input rstr-search",
1769
+ value: filter,
1770
+ placeholder: t("source.filterPlaceholder"),
1771
+ "aria-label": t("source.filter"),
1772
+ onChange: (event) => setFilter(event.target.value),
1773
+ }),
1774
+ h("span", { className: "rstr-muted rstr-small" }, t("source.filterCount", { shown: shown.length, total: all.length })),
1775
+ ),
1776
+ shown.map((roster) =>
1777
+ h(
1778
+ "div",
1779
+ { key: roster.id, className: "rstr-source-roster" },
1780
+ h("span", { className: "rstr-mono" }, roster.id),
1781
+ h("span", { className: "rstr-muted" }, roster.title ?? ""),
1782
+ h(Badge, { tone: "neutral" }, `${(roster.members ?? []).length}`),
1783
+ ),
1784
+ ),
1785
+ shown.length === 0 ? h("p", { className: "rstr-muted" }, t("panel.noMatch")) : null,
1786
+ (detail.body?.diagnostics ?? []).length > 0 ? h(DiagnosticList, { items: detail.body.diagnostics }) : null,
1787
+ );
1788
+ }
1789
+
1721
1790
  // 刷新是档案源自己的动作:绕过 TTL 重新回源,而不是重读本地快照。
1722
1791
  const head = h(
1723
1792
  "div",
@@ -1810,22 +1879,7 @@ window.__ModuleLoader__.load({
1810
1879
  ? h("p", { className: "rstr-muted" }, t("source.loading"))
1811
1880
  : detail.status === "error"
1812
1881
  ? h("p", { className: "rstr-muted" }, t("misc.failed"))
1813
- : h(
1814
- "div",
1815
- { className: "rstr-source-detail" },
1816
- (detail.body?.rosters ?? []).map((roster) =>
1817
- h(
1818
- "div",
1819
- { key: roster.id, className: "rstr-source-roster" },
1820
- h("span", { className: "rstr-mono" }, roster.id),
1821
- h("span", { className: "rstr-muted" }, roster.title ?? ""),
1822
- h(Badge, { tone: "neutral" }, `${(roster.members ?? []).length}`),
1823
- ),
1824
- ),
1825
- (detail.body?.diagnostics ?? []).length > 0
1826
- ? h(DiagnosticList, { items: detail.body.diagnostics })
1827
- : null,
1828
- )
1882
+ : renderDetail()
1829
1883
  : null,
1830
1884
  ),
1831
1885
  ),
@@ -2447,6 +2501,18 @@ window.__ModuleLoader__.load({
2447
2501
  border-left: 2px solid var(--dsw-alias-border-l2);
2448
2502
  margin-left: 2px;
2449
2503
  }
2504
+ .rstr-source-filter {
2505
+ display: flex;
2506
+ align-items: center;
2507
+ gap: 10px;
2508
+ min-width: 0;
2509
+ padding: 2px 0 6px;
2510
+ }
2511
+ .rstr-source-filter .rstr-search {
2512
+ flex: 1 1 auto;
2513
+ min-width: 0;
2514
+ max-width: 320px;
2515
+ }
2450
2516
  .rstr-source-roster {
2451
2517
  display: grid;
2452
2518
  grid-template-columns: minmax(120px, 180px) minmax(0, 1fr) auto;
package/index.js CHANGED
@@ -25,7 +25,7 @@ import { validateRoster } from "./lib/validate.js";
25
25
  import { validateRoster2 } from "./lib/roster2.js";
26
26
  import { sendError, sendJson, createAuthGate, readJsonBody, requireMethod } from "./lib/http.js";
27
27
  import { buildPanelSnapshot, installCollection, loadRemotePanel, resolveRoots } from "./lib/service.js";
28
- import { fetchCollection } from "./lib/remote.js";
28
+ import { createTtlCache, fetchCollection } from "./lib/remote.js";
29
29
  import { registerRosterTools } from "./lib/tools.js";
30
30
  import { deleteRosterDocument, readRosterDocument, writeRosterDocument } from "./lib/write.js";
31
31
 
@@ -129,6 +129,10 @@ export function apply(ctx, config) {
129
129
  "library route",
130
130
  );
131
131
 
132
+ // 同一份 collection 一分钟内被展开两次,第二次不该再打一轮请求。
133
+ // 缓存的是**已校验的结果**,不是响应体,所以不存在"把非法内容缓存下来"的问题。
134
+ const collectionCache = createTtlCache({ ttlMs: 60_000 });
135
+
132
136
  register(
133
137
  "/plugins/dsh-agent-roster/collection",
134
138
  async (req, res) => {
@@ -145,7 +149,9 @@ export function apply(ctx, config) {
145
149
  sendJson(res, 404, { ok: false, error: { code: "collection-not-found" }, known: remote.collections.map((item) => item.id) });
146
150
  return;
147
151
  }
148
- const fetched = await fetchCollection(collection);
152
+ const cacheKey = `${source}::${id}`;
153
+ const cached = query.get("refresh") === "1" ? null : collectionCache.get(cacheKey);
154
+ const fetched = cached ?? collectionCache.set(cacheKey, await fetchCollection(collection));
149
155
  sendJson(res, 200, {
150
156
  ok: fetched.diagnostics.every((item) => item.severity !== "error"),
151
157
  collection: {
package/lib/remote.js CHANGED
@@ -550,76 +550,75 @@ export function validateRegistry(value, meta = {}) {
550
550
  * @param {(id: string) => boolean} [options.only] 只要这几份(面板按需拉取时用)
551
551
  * @returns {Promise<{ collection: object, rosters: object[], diagnostics: object[] }>}
552
552
  */
553
- export async function fetchCollection(collection, options = {}) {
554
- const fetchImpl = options.fetchImpl ?? globalThis.fetch;
555
- const wanted = collection.rosters.filter((id) => options.only === undefined || options.only(id));
556
- const rosters = [];
557
- const diagnostics = [];
558
-
559
- for (const rosterId of wanted) {
560
- let loaded = null;
561
- let lastError = "";
562
- const listedPath = collection.files?.[rosterId] ?? null;
563
- const candidateNames = listedPath === null ? ["roster.yaml", "roster.yml", "roster.json"] : [null];
564
- for (const fileName of candidateNames) {
565
- const url = listedPath === null ? collectionFileUrl(collection, rosterId, fileName) : collectionFileUrl(collection, rosterId, "");
566
- try {
567
- const response = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
568
- if (!response.ok) {
569
- lastError = `HTTP ${String(response.status)}`;
570
- continue;
571
- }
572
- loaded = { url, fileName, text: await response.text() };
573
- break;
574
- } catch (error) {
575
- lastError = error instanceof Error ? error.message : String(error);
576
- }
577
- }
578
- if (loaded === null) {
579
- diagnostics.push(diagnostic("warn", "collection-fetch-failed", `${collection.id}/${rosterId} 拉取失败:${lastError || "未知原因"}`));
580
- // 没有 files 映射时退回的是 v1 路径;v2 库在那条路径上必然 404。
581
- // 这时最可能的原因是"索引是升级前缓存的",提示一句比让用户猜有用。
582
- if (listedPath === null) {
583
- diagnostics.push(
584
- diagnostic(
585
- "info",
586
- "index-may-be-stale",
587
- `${collection.id} 的索引里没有 files 映射,回退到了 v1 路径;若刚升级过插件,先刷新索引(缓存按版本失效,也可删 .index-cache)`,
588
- ),
589
- );
553
+ /**
554
+ * 拉一份档案(成功就返回 roster,失败就返回诊断)。
555
+ *
556
+ * 抽成独立函数是为了让 `fetchCollection` 能并发跑多份:串行 21 次 HTTPS 往返
557
+ * 实测 2.4 秒,用户点「展开」就是等它。并发只改变等待形状,不改变结果形状。
558
+ */
559
+ async function fetchOneRoster(collection, rosterId, { fetchImpl }) {
560
+ let loaded = null;
561
+ let lastError = "";
562
+ const listedPath = collection.files?.[rosterId] ?? null;
563
+ const candidateNames = listedPath === null ? ["roster.yaml", "roster.yml", "roster.json"] : [null];
564
+ for (const fileName of candidateNames) {
565
+ const url = listedPath === null ? collectionFileUrl(collection, rosterId, fileName) : collectionFileUrl(collection, rosterId, "");
566
+ try {
567
+ const response = await fetchImpl(url, { signal: AbortSignal.timeout(TIMEOUT_MS) });
568
+ if (!response.ok) {
569
+ lastError = `HTTP ${String(response.status)}`;
570
+ continue;
590
571
  }
591
- continue;
572
+ loaded = { url, fileName, text: await response.text() };
573
+ break;
574
+ } catch (error) {
575
+ lastError = error instanceof Error ? error.message : String(error);
592
576
  }
593
- const parsed = parseDocumentText(loaded.text, { file: loaded.url });
594
- if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === ROSTER2_KIND) {
577
+ }
578
+ if (loaded === null) {
579
+ const diagnostics = [
580
+ diagnostic("warn", "collection-fetch-failed", `${collection.id}/${rosterId} 拉取失败:${lastError || "未知原因"}`),
581
+ ];
582
+ // 没有 files 映射时退回的是 v1 路径;v2 库在那条路径上必然 404。
583
+ // 这时最可能的原因是"索引是升级前缓存的",提示一句比让用户猜有用。
584
+ if (listedPath === null) {
595
585
  diagnostics.push(
586
+ diagnostic(
587
+ "info",
588
+ "index-may-be-stale",
589
+ `${collection.id} 的索引里没有 files 映射,回退到了 v1 路径;若刚升级过插件,先刷新索引(缓存按版本失效,也可删 .index-cache)`,
590
+ ),
591
+ );
592
+ }
593
+ return { roster: null, diagnostics };
594
+ }
595
+ const parsed = parseDocumentText(loaded.text, { file: loaded.url });
596
+ if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === ROSTER2_KIND) {
597
+ return {
598
+ roster: null,
599
+ diagnostics: [
596
600
  diagnostic(
597
601
  "warn",
598
602
  "collection-roster-needs-roles",
599
603
  `${collection.id}/${rosterId} 是 v2 组装,成员是角色引用,单个文件拉不到角色目录;索引应指向 dist/ 里的编译产物`,
600
604
  ),
601
- );
602
- continue;
603
- }
604
- let checked;
605
- if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === COMPILED_KIND) {
606
- const compiled = validateCompiled(parsed.value, { file: loaded.url });
607
- if (!compiled.ok) {
608
- diagnostics.push(...compiled.diagnostics);
609
- continue;
610
- }
611
- checked = validateRoster(flattenCompiledToRoster(compiled.compiled), { file: loaded.url, expectedId: rosterId });
612
- checked = { ...checked, format: "compiled" };
613
- } else {
614
- checked = parsed.ok
615
- ? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
616
- : { ok: false, diagnostics: parsed.diagnostics };
617
- }
618
- if (!checked.ok) {
619
- diagnostics.push(...checked.diagnostics);
620
- continue;
621
- }
622
- rosters.push({
605
+ ],
606
+ };
607
+ }
608
+ let checked;
609
+ if (parsed.ok && parsed.value !== null && typeof parsed.value === "object" && parsed.value.kind === COMPILED_KIND) {
610
+ const compiled = validateCompiled(parsed.value, { file: loaded.url });
611
+ if (!compiled.ok) return { roster: null, diagnostics: compiled.diagnostics };
612
+ checked = validateRoster(flattenCompiledToRoster(compiled.compiled), { file: loaded.url, expectedId: rosterId });
613
+ checked = { ...checked, format: "compiled" };
614
+ } else {
615
+ checked = parsed.ok
616
+ ? validateRoster(parsed.value, { file: loaded.url, expectedId: rosterId })
617
+ : { ok: false, diagnostics: parsed.diagnostics };
618
+ }
619
+ if (!checked.ok) return { roster: null, diagnostics: checked.diagnostics };
620
+ return {
621
+ roster: {
623
622
  ...checked.roster,
624
623
  ...(checked.format === undefined ? {} : { format: checked.format }),
625
624
  origin: {
@@ -631,12 +630,86 @@ export async function fetchCollection(collection, options = {}) {
631
630
  path: collection.path,
632
631
  url: loaded.url,
633
632
  },
634
- });
635
- }
633
+ },
634
+ diagnostics: [],
635
+ };
636
+ }
637
+
638
+ /** 并发上限:raw.githubusercontent 是无认证的,6 条并行既快又不会被当成抓取器。 */
639
+ export const FETCH_CONCURRENCY = 6;
640
+
641
+ /**
642
+ * 拉取一个 collection 里的档案。
643
+ *
644
+ * 并发上限由 `options.concurrency` 覆盖(测试用它把时序摊平);返回的 `rosters`
645
+ * 与 `diagnostics` 都保持索引顺序,和串行版本的输出逐字一致。
646
+ *
647
+ * @param {object} collection `validateRegistry` 校验过的 collection
648
+ * @param {object} [options]
649
+ * @param {string} [options.only] 只拉这一份(测试/预览用)
650
+ * @param {typeof fetch} [options.fetchImpl]
651
+ * @param {number} [options.concurrency]
652
+ * @returns {Promise<{collection: object, rosters: object[], diagnostics: object[]}>}
653
+ */
654
+ export async function fetchCollection(collection, options = {}) {
655
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch;
656
+ const wanted = collection.rosters.filter((id) => options.only === undefined || options.only(id));
657
+ const concurrency = Math.max(1, Math.min(Number.isInteger(options.concurrency) ? options.concurrency : FETCH_CONCURRENCY, wanted.length || 1));
658
+ const results = new Array(wanted.length);
659
+ let next = 0;
660
+ const workers = Array.from({ length: Math.min(concurrency, wanted.length) }, async () => {
661
+ for (;;) {
662
+ const index = next;
663
+ next += 1;
664
+ if (index >= wanted.length) return;
665
+ results[index] = await fetchOneRoster(collection, wanted[index], { fetchImpl });
666
+ }
667
+ });
668
+ await Promise.all(workers);
636
669
 
670
+ const rosters = [];
671
+ const diagnostics = [];
672
+ for (const result of results) {
673
+ if (result === undefined) continue;
674
+ if (result.roster !== null) rosters.push(result.roster);
675
+ diagnostics.push(...result.diagnostics);
676
+ }
637
677
  return { collection, rosters, diagnostics };
638
678
  }
639
679
 
680
+ /**
681
+ * 一份进程内的 TTL 缓存。
682
+ *
683
+ * 用途只有一个:同一个 collection 在短时间内被展开两次时,第二次不该再打 21 个请求。
684
+ * 缓存的是**结果**(已校验的档案),不是响应体——所以没有"缓存了不合法内容"的风险。
685
+ *
686
+ * @param {{ttlMs?: number, now?: () => number}} [options]
687
+ */
688
+ export function createTtlCache({ ttlMs = 60_000, now = () => Date.now() } = {}) {
689
+ const entries = new Map();
690
+ return {
691
+ get(key) {
692
+ const entry = entries.get(key);
693
+ if (entry === undefined) return null;
694
+ if (now() - entry.at >= ttlMs) {
695
+ entries.delete(key);
696
+ return null;
697
+ }
698
+ return entry.value;
699
+ },
700
+ set(key, value) {
701
+ entries.set(key, { at: now(), value });
702
+ return value;
703
+ },
704
+ clear() {
705
+ entries.clear();
706
+ },
707
+ get size() {
708
+ return entries.size;
709
+ },
710
+ };
711
+ }
712
+
640
713
  /** 拼一份档案的 raw 地址(relativePath 由索引给出,或回退到 v1 的 `<id>/roster.<ext>`)。 */
641
714
  export function collectionFileUrl(collection, rosterId, fileName) {
642
715
  const repo = collection.repo.replace(/^github:/, "");
package/locale/en.json CHANGED
@@ -298,5 +298,8 @@
298
298
  "undeclared": "not declared, runtime uses {actual}",
299
299
  "unknown": "deployment unreadable"
300
300
  }
301
- }
301
+ },
302
+ "source.filter": "Filter this collection",
303
+ "source.filterPlaceholder": "Filter by id, title or member",
304
+ "source.filterCount": "{shown} of {total}"
302
305
  }
package/locale/zh.json CHANGED
@@ -298,5 +298,8 @@
298
298
  "undeclared": "未声明,运行时用 {actual}",
299
299
  "unknown": "读不到部署配置"
300
300
  }
301
- }
301
+ },
302
+ "source.filter": "在这份集合里查找",
303
+ "source.filterPlaceholder": "按 id、标题或成员名过滤",
304
+ "source.filterCount": "{shown} / {total}"
302
305
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stofone/dsh-agent-roster",
3
- "version": "0.10.7",
3
+ "version": "0.11.0",
4
4
  "description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
5
5
  "type": "module",
6
6
  "main": "index.js",