@stofone/dsh-agent-roster 0.11.1 → 0.12.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,29 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.12.0] - 2026-11-03
8
+
9
+ ### The panel answers "which one", not "show me everything"
10
+
11
+ Until now the panel was organised by **collection**: expand a source, then read a list. But the real
12
+ question is almost always the opposite — "I need someone who watches data quality" — so the entry
13
+ point is now one search field above the tabs.
14
+
15
+ - `GET /plugins/dsh-agent-roster/search?q=` searches this machine and every subscribed source in one
16
+ request, reusing the collection cache (60s) so repeated typing does not hit the network again.
17
+ - Ranking is explainable: every match reports `hits[{field, label, text}]` ("标签:数据质量"), and id
18
+ matches outrank title matches outrank tag matches outrank member roles. Multiple words are **AND**:
19
+ "data kubernetes" matches nothing here, instead of matching everything that has "data".
20
+ - A result that lives in another library says so ("来自 agent-roster-collective") and can be pulled
21
+ from the row itself; a local one opens straight into the roster list filtered to that id.
22
+ - `lib/search.js` is a pure function — no filesystem, no network — so the ranking is unit-tested.
23
+
24
+ ### Added
25
+
26
+ - `lib/search.js` with `searchRosters`, `searchableFields`, `tokenize`; 6 tests.
27
+ - `/plugins/dsh-agent-roster/search` route (local + remote, bounded source concurrency).
28
+ - Panel lookup field, result rows with match provenance, and `lookup.*` strings in both locales.
29
+
7
30
  ## [0.11.1] - 2026-11-03
8
31
 
9
32
  ### Fixed
@@ -557,7 +580,8 @@
557
580
  - 工具返回值含 `undefined` 键,运行时按 `value is not lossless JSON` 整次拒绝;缺失值统一写 `null`,
558
581
  且所有返回值先过 `lib/json.js#lossless`。
559
582
 
560
- [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.1...main
583
+ [Unreleased]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.12.0...main
584
+ [0.12.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.1...v0.12.0
561
585
  [0.11.1]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.11.0...v0.11.1
562
586
  [0.11.0]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.7...v0.11.0
563
587
  [0.10.7]: https://github.com/dsh-agent-roster/dsh-agent-roster/compare/v0.10.6...v0.10.7
package/README.md CHANGED
@@ -306,6 +306,23 @@ id belongs to the team, and the plugin never writes team state. What it does own
306
306
  failure that only exists on a red card never reaches the next compile, which is exactly how a gate
307
307
  turns into decoration.
308
308
 
309
+ ## Find one roster, not a catalogue
310
+
311
+ The panel used to open on a list of collections. That answers "what is published"; the question people
312
+ actually arrive with is "who can do this". So the first control is now one search field above the tabs:
313
+
314
+ ```
315
+ 要找什么能力?例:数据质量、发版、代码评审
316
+ ```
317
+
318
+ It searches this machine and every subscribed source in one request. Each row says **why** it matched
319
+ (`标签:数据质量 · 成员角色:数据质量工程师`) and where it lives (`来自 agent-roster-collective`), and
320
+ carries the action you need next — open it locally, or pull that collection from the row.
321
+
322
+ Multiple words are AND, not OR: `data kubernetes` returns nothing rather than every roster that merely
323
+ mentions "data". Ranking is a pure function in `lib/search.js`, so it is unit-tested rather than
324
+ eyeballed.
325
+
309
326
  ## Tools
310
327
 
311
328
  | Tool | Purpose |
@@ -492,7 +509,7 @@ test/ unit tests
492
509
 
493
510
  Verified on this machine:
494
511
 
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).
512
+ - `npm test` — 307/307 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).
496
513
  - `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.
497
514
  - CLI: `validate --dir examples` returns exit 0 over both example libraries.
498
515
  - 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
@@ -292,6 +292,22 @@ node bin/cli.mjs compile --roster examples-orchestration/rosters/core-review \
292
292
 
293
293
  回执由执行施工图的队长会话写,不由插件写:任务 id 属于团队,插件从不写团队状态。插件负责的是合并——只活在红卡上的失败,下一次编译就消失了,门也就变成了装饰。
294
294
 
295
+ ## 找一份档案,而不是翻一个目录
296
+
297
+ 面板原来从「合集」进:先展开一个来源,再看里面有什么。那回答的是「发布了什么」,而人真正带来的
298
+ 问题是「谁会做这件事」。所以现在第一个控件就是页签上方的一个搜索框:
299
+
300
+ ```
301
+ 要找什么能力?例:数据质量、发版、代码评审
302
+ ```
303
+
304
+ 一次请求同时搜本机与所有订阅来源。每一行都写清**为什么命中**(`标签:数据质量 · 成员角色:数据质量工程师`)
305
+ 与**它住在哪**(`来自 agent-roster-collective`),并且把下一步动作放在行内:本机的直接打开,远端的
306
+ 当场把这个集合拉下来。
307
+
308
+ 多个词是「且」不是「或」:`data kubernetes` 一条都不返,而不是把所有提到 data 的档案都堆上来。
309
+ 排序逻辑是 `lib/search.js` 里的纯函数,因此它被单测钉住,而不是靠肉眼看着顺。
310
+
295
311
  ## 工具
296
312
 
297
313
  | 工具 | 作用 |
@@ -463,7 +479,7 @@ test/ 单测
463
479
 
464
480
  本机已验证:
465
481
 
466
- - `npm test` —— 300/300 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
482
+ - `npm test` —— 307/307 通过(格式、解析、校验、扫描、路径、桥接、核对四桶、摘要与溯源、档案差异、能力要求与能力闸门、档案库组织、索引 schema、无损 JSON、写入计划与执行、六条路由、快照与提示、两本词典一致、工具契约、客户端加载器契约、包清单、配置视图、样式 token 约束、声明层字段与队长段、schema 与常量对齐、v2 角色/组装/编译三层及其 schema 对齐、v1→v2 迁移、执行回执并入施工图)。
467
483
  - `npm run smoke` —— 用真实 DSH bundle 的 `defineTool` 注册六个工具,逐个调用:输出全部通过无损校验、`path` 覆盖生效、释放干净;同一条命令随后用桩模块表加载 `client.js`,断言三处槽位注册、样式只注入一次、effect 可回收。
468
484
  - CLI:`validate --dir examples` 对两座示例库退出码 0。
469
485
  - 用插件管理器真的装进了本机 profile(`plugin_manager install_bundle` 指向本仓库 → `link:` 依赖 + bundle 条目),重启后按链接读取本仓库文件。
package/client.js CHANGED
@@ -34,6 +34,7 @@ window.__ModuleLoader__.load({
34
34
  const INSTALL_ROUTE = "/plugins/dsh-agent-roster/install";
35
35
  const COLLECTION_ROUTE = "/plugins/dsh-agent-roster/collection";
36
36
  const APPLY_ROUTE = "/plugins/dsh-agent-roster/apply";
37
+ const SEARCH_ROUTE = "/plugins/dsh-agent-roster/search";
37
38
 
38
39
  const EFFORTS = ["low", "medium", "high", "default"];
39
40
  const LANGUAGES = ["auto", "zh", "en"];
@@ -1283,6 +1284,39 @@ window.__ModuleLoader__.load({
1283
1284
  const [importing, setImporting] = useState(false);
1284
1285
  const [removing, setRemoving] = useState(null);
1285
1286
  const [toast, setToast] = useState(null);
1287
+ // 「找一份档案」是首屏动作,不是某个页签里的筛选器:它同时问本机与所有订阅来源。
1288
+ const [lookup, setLookup] = useState("");
1289
+ const [lookupState, setLookupState] = useState({ status: "idle", body: null });
1290
+ const [pullingCollection, setPullingCollection] = useState("");
1291
+
1292
+ useEffect(() => {
1293
+ const needle = lookup.trim();
1294
+ if (needle.length < 2) {
1295
+ setLookupState({ status: "idle", body: null });
1296
+ return;
1297
+ }
1298
+ const controller = new AbortController();
1299
+ // 停止输入之后才发请求:每敲一个字都打一次,等于自己给自己造并发。
1300
+ const timer = setTimeout(() => {
1301
+ setLookupState((current) => ({ status: "loading", body: current.body }));
1302
+ readJson(`${SEARCH_ROUTE}?q=${encodeURIComponent(needle)}`, { signal: controller.signal })
1303
+ .then((result) => {
1304
+ if (controller.signal.aborted) return;
1305
+ setLookupState(
1306
+ result.ok && result.body !== null
1307
+ ? { status: "ready", body: result.body }
1308
+ : { status: "error", body: null },
1309
+ );
1310
+ })
1311
+ .catch(() => {
1312
+ if (!controller.signal.aborted) setLookupState({ status: "error", body: null });
1313
+ });
1314
+ }, 220);
1315
+ return () => {
1316
+ clearTimeout(timer);
1317
+ controller.abort();
1318
+ };
1319
+ }, [lookup]);
1286
1320
 
1287
1321
  useEffect(() => {
1288
1322
  if (toast === null) return;
@@ -1321,6 +1355,38 @@ window.__ModuleLoader__.load({
1321
1355
  });
1322
1356
  }, [rosters, query, selectedTags]);
1323
1357
 
1358
+ async function openMatch(match) {
1359
+ if (match.scope === "local") {
1360
+ setLookup("");
1361
+ setTab("rosters");
1362
+ setQuery(match.rosterId);
1363
+ return;
1364
+ }
1365
+ setPullingCollection(match.collectionId ?? "");
1366
+ try {
1367
+ const result = await readJson(INSTALL_ROUTE, {
1368
+ method: "POST",
1369
+ headers: { "content-type": "application/json" },
1370
+ body: JSON.stringify({ collection: match.collectionId }),
1371
+ });
1372
+ if (!result.ok || result.body === null) {
1373
+ setToast(`${t("misc.failed")} (${String(result.status)})`);
1374
+ return;
1375
+ }
1376
+ const summary = t("source.pulled", {
1377
+ written: result.body.written ?? 0,
1378
+ unchanged: result.body.unchanged ?? 0,
1379
+ conflicts: result.body.conflicts ?? 0,
1380
+ });
1381
+ setToast(summary);
1382
+ library.reload();
1383
+ } catch {
1384
+ setToast(t("misc.failed"));
1385
+ } finally {
1386
+ setPullingCollection("");
1387
+ }
1388
+ }
1389
+
1324
1390
  function toggleTag(tag) {
1325
1391
  setSelectedTags((current) => {
1326
1392
  const next = new Set(current);
@@ -1454,6 +1520,82 @@ window.__ModuleLoader__.load({
1454
1520
  ),
1455
1521
  );
1456
1522
 
1523
+ // 搜索态压过页签:输入了问题就只看答案,不再让人先去挑一个合集。
1524
+ let lookupView = null;
1525
+ const needle = lookup.trim();
1526
+ if (needle !== "") {
1527
+ const body = lookupState.body;
1528
+ const matches = body?.matches ?? [];
1529
+ lookupView = h(
1530
+ "div",
1531
+ { className: "rstr-results" },
1532
+ h(
1533
+ "p",
1534
+ { className: "rstr-results-head" },
1535
+ lookupState.status === "loading" && body === null
1536
+ ? t("lookup.searching")
1537
+ : t("lookup.counts", {
1538
+ total: String(matches.length),
1539
+ local: String(body?.counts?.local ?? 0),
1540
+ remote: String(body?.counts?.remote ?? 0),
1541
+ }),
1542
+ ),
1543
+ lookupState.status === "error"
1544
+ ? h("p", { className: "rstr-muted" }, t("lookup.failed"))
1545
+ : null,
1546
+ lookupState.status === "ready" && matches.length === 0
1547
+ ? h("p", { className: "rstr-muted" }, t("lookup.empty", { q: needle }))
1548
+ : null,
1549
+ matches.map((match) =>
1550
+ h(
1551
+ "article",
1552
+ { key: `${match.scope}:${match.collectionId ?? ""}:${match.rosterId}`, className: "rstr-row" },
1553
+ h(
1554
+ "div",
1555
+ { className: "rstr-row-body" },
1556
+ h("code", { className: "rstr-row-id", title: match.rosterId }, match.rosterId),
1557
+ h(
1558
+ "div",
1559
+ { className: "rstr-row-text" },
1560
+ h("h3", { className: "rstr-row-title" }, match.title === "" ? match.rosterId : match.title),
1561
+ h(
1562
+ "p",
1563
+ { className: "rstr-row-desc" },
1564
+ match.hits.map((hit) => `${hit.label}:${hit.text}`).join(" · "),
1565
+ ),
1566
+ ),
1567
+ h(
1568
+ "div",
1569
+ { className: "rstr-row-stats" },
1570
+ h("span", { className: "rstr-stat" }, t("lookup.members", { count: String(match.members) })),
1571
+ h(
1572
+ "span",
1573
+ { className: "rstr-stat" },
1574
+ match.scope === "local"
1575
+ ? t("lookup.local")
1576
+ : t("lookup.remoteFrom", { collection: match.collectionTitle ?? match.collectionId ?? "" }),
1577
+ ),
1578
+ ),
1579
+ h(
1580
+ "div",
1581
+ { className: "rstr-row-actions" },
1582
+ h(
1583
+ "button",
1584
+ {
1585
+ type: "button",
1586
+ className: "rstr-btn rstr-btn-sm",
1587
+ disabled: match.scope === "remote" && pullingCollection !== "",
1588
+ onClick: () => openMatch(match),
1589
+ },
1590
+ match.scope === "local" ? t("lookup.open") : t("lookup.pull"),
1591
+ ),
1592
+ ),
1593
+ ),
1594
+ ),
1595
+ ),
1596
+ );
1597
+ }
1598
+
1457
1599
  let content = null;
1458
1600
 
1459
1601
  if (tab === "rosters") {
@@ -1599,8 +1741,31 @@ window.__ModuleLoader__.load({
1599
1741
  { className: "rstr-page" },
1600
1742
  header,
1601
1743
  banner,
1602
- tabs,
1603
- content,
1744
+ h(
1745
+ "div",
1746
+ { className: "rstr-lookup" },
1747
+ h("input", {
1748
+ className: "rstr-input rstr-lookup-input",
1749
+ value: lookup,
1750
+ placeholder: t("lookup.placeholder"),
1751
+ "aria-label": t("lookup.placeholder"),
1752
+ onChange: (event) => setLookup(event.target.value),
1753
+ onKeyDown: (event) => {
1754
+ if (event.key !== "Escape") return;
1755
+ setLookup("");
1756
+ event.stopPropagation();
1757
+ },
1758
+ }),
1759
+ lookup === ""
1760
+ ? h("span", { className: "rstr-lookup-hint" }, t("lookup.hint"))
1761
+ : h(
1762
+ "button",
1763
+ { type: "button", className: "rstr-btn rstr-btn-sm rstr-btn-ghost", onClick: () => setLookup("") },
1764
+ t("lookup.clear"),
1765
+ ),
1766
+ ),
1767
+ lookupView === null ? tabs : null,
1768
+ lookupView === null ? content : lookupView,
1604
1769
  h(
1605
1770
  "footer",
1606
1771
  { className: "rstr-foot" },
@@ -2347,6 +2512,19 @@ window.__ModuleLoader__.load({
2347
2512
 
2348
2513
  /* ---- 账本(行) ------------------------------------------------------- */
2349
2514
 
2515
+ .rstr-lookup { display: flex; align-items: center; gap: 10px; padding: 0 28px 12px; min-width: 0; }
2516
+
2517
+ .rstr-lookup-input { flex: 1 1 auto; max-width: 560px; }
2518
+
2519
+ .rstr-lookup-hint { font-size: 12px; color: var(--dsw-alias-label-secondary); }
2520
+
2521
+ /* 清除按钮不参与压缩:窄面板里被挤成竖排的两个字最难认。 */
2522
+ .rstr-lookup > .rstr-btn { flex: 0 0 auto; white-space: nowrap; }
2523
+
2524
+ .rstr-results { display: flex; flex-direction: column; min-width: 0; padding: 0 28px 28px; border-top: 1px solid var(--dsw-alias-border-l2); }
2525
+
2526
+ .rstr-results-head { margin: 12px 0 4px; font-size: 12px; color: var(--dsw-alias-label-secondary); }
2527
+
2350
2528
  .rstr-cards { display: flex; flex-direction: column; min-width: 0; border-top: 1px solid var(--dsw-alias-border-l2); }
2351
2529
 
2352
2530
  .rstr-row {
package/index.js CHANGED
@@ -26,6 +26,7 @@ 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
28
  import { createTtlCache, fetchCollection } from "./lib/remote.js";
29
+ import { searchRosters } from "./lib/search.js";
29
30
  import { registerRosterTools } from "./lib/tools.js";
30
31
  import { deleteRosterDocument, readRosterDocument, writeRosterDocument } from "./lib/write.js";
31
32
 
@@ -173,6 +174,87 @@ export function apply(ctx, config) {
173
174
  "collection route",
174
175
  );
175
176
 
177
+ // 搜索是「找一份档案」的入口:一次请求横跨本机与所有订阅来源。
178
+ // 远端的拉取复用 collection 的 60s 缓存,所以连续输入不会对同一个集合重复打网络。
179
+ register(
180
+ "/plugins/dsh-agent-roster/search",
181
+ async (req, res) => {
182
+ if (guard(req, res)) return;
183
+ if (!requireMethod(req, res, "GET")) return;
184
+ const query = new URL(req.url ?? "/", "http://localhost").searchParams;
185
+ const q = (query.get("q") ?? "").trim();
186
+ if (q === "") {
187
+ sendJson(res, 200, { ok: true, q: "", tokens: [], total: 0, matches: [], counts: { local: 0, remote: 0 }, diagnostics: [] });
188
+ return;
189
+ }
190
+
191
+ const library = collectLibrary(ctx, settings, { name: pkg.name, version: pkg.version });
192
+ const entries = [];
193
+ let localCount = 0;
194
+ for (const workspace of library.workspaces) {
195
+ for (const roster of workspace.rosters) {
196
+ entries.push({ scope: "local", roster, libraryId: workspace.rootDir ?? null, file: roster.file ?? null });
197
+ localCount += 1;
198
+ }
199
+ }
200
+
201
+ const diagnostics = [];
202
+ const collections = [];
203
+ try {
204
+ const remote = await loadRemotePanel(settings, {});
205
+ for (const item of remote.diagnostics ?? []) if (item.severity !== "info") diagnostics.push(item);
206
+ for (const collection of remote.collections ?? []) collections.push(collection);
207
+ } catch (error) {
208
+ diagnostics.push({
209
+ severity: "warn",
210
+ code: "search-index-unavailable",
211
+ message: `订阅索引拉不到,本次只搜索本机档案:${error instanceof Error ? error.message : String(error)}`,
212
+ });
213
+ }
214
+
215
+ // 来源两两并发:一个慢集合不该把所有来源都拖住,也不该一次打三十个请求。
216
+ const sourceConcurrency = 2;
217
+ let cursor = 0;
218
+ const fetchNext = async () => {
219
+ while (cursor < collections.length) {
220
+ const collection = collections[cursor];
221
+ cursor += 1;
222
+ const key = `${collection.source ?? ""}::${collection.id}`;
223
+ try {
224
+ const cached = collectionCache.get(key);
225
+ const fetched = cached ?? collectionCache.set(key, await fetchCollection(collection));
226
+ for (const roster of fetched.rosters ?? []) {
227
+ entries.push({
228
+ scope: "remote",
229
+ roster,
230
+ collectionId: collection.id,
231
+ collectionTitle: collection.title ?? null,
232
+ file: roster.file ?? null,
233
+ });
234
+ }
235
+ for (const item of fetched.diagnostics ?? []) if (item.severity === "error") diagnostics.push(item);
236
+ } catch (error) {
237
+ diagnostics.push({
238
+ severity: "warn",
239
+ code: "search-source-failed",
240
+ message: `${collection.id} 拉取失败:${error instanceof Error ? error.message : String(error)}`,
241
+ });
242
+ }
243
+ }
244
+ };
245
+ await Promise.all(Array.from({ length: Math.min(sourceConcurrency, collections.length) }, fetchNext));
246
+
247
+ const result = searchRosters(q, entries);
248
+ const remoteCount = entries.length - localCount;
249
+ sendJson(res, 200, {
250
+ ...result,
251
+ counts: { local: localCount, remote: remoteCount },
252
+ diagnostics: diagnostics.slice(0, 20),
253
+ });
254
+ },
255
+ "search route",
256
+ );
257
+
176
258
  register(
177
259
  "/plugins/dsh-agent-roster/locale",
178
260
  async (req, res) => {
package/lib/search.js ADDED
@@ -0,0 +1,155 @@
1
+ /**
2
+ * 找一份档案,而不是浏览一堆合集。
3
+ *
4
+ * 面板原来的组织单位是「合集」:先展开一个来源,再在里面翻。但真实任务几乎总是反向的——
5
+ * 「我需要一个能盯数据质量的队伍」,先知道自己要什么,再去找谁有。所以这里把搜索做成
6
+ * 一等能力:一个问题,横跨本机与所有订阅来源,命中的每一行都要说清**为什么命中**。
7
+ *
8
+ * 两条刻意的规则:
9
+ * 1. **多词是「且」**:`data quality` 要求每个词都落在某个字段里,任一落空即不入选。
10
+ * 否则「评分」会把一堆只沾了「分」的档案捞上来,搜索结果比列表更难读。
11
+ * 2. **命中要有出处**:每行返回 `hits[{field,label,text}]`,界面直接显示「命中 标签:数据质量」。
12
+ * 只给分数是没法解释的——用户凭什么信这一条排在前面。
13
+ *
14
+ * 纯函数:不读文件、不联网,输入就是已经装配好的档案视图。
15
+ */
16
+
17
+ export const SEARCH_LIMIT = 20;
18
+
19
+ /** 字段权重:id 是名字,title 是自我描述,tags 是别人怎么找它。 */
20
+ export const SEARCH_FIELDS = [
21
+ { field: "id", weight: 100, label: "档案 id" },
22
+ { field: "title", weight: 56, label: "标题" },
23
+ { field: "tags", weight: 48, label: "标签" },
24
+ { field: "skills", weight: 40, label: "技能要求" },
25
+ { field: "requires", weight: 36, label: "能力要求" },
26
+ { field: "memberRole", weight: 34, label: "成员角色" },
27
+ { field: "memberName", weight: 26, label: "成员名" },
28
+ { field: "memberTitle", weight: 22, label: "成员标题" },
29
+ { field: "description", weight: 18, label: "描述" },
30
+ { field: "notes", weight: 12, label: "备注" },
31
+ ];
32
+
33
+ const WEIGHT = new Map(SEARCH_FIELDS.map((item) => [item.field, item]));
34
+
35
+ /** 把一个词切成可比较的片段:空白与中文标点都算分隔。 */
36
+ export function tokenize(query) {
37
+ if (typeof query !== "string") return [];
38
+ return query
39
+ .toLowerCase()
40
+ .split(/[\s,,、;;|/]+/)
41
+ .map((token) => token.trim())
42
+ .filter((token) => token !== "");
43
+ }
44
+
45
+ function push(list, field, text) {
46
+ if (typeof text !== "string") return;
47
+ const value = text.trim();
48
+ if (value === "") return;
49
+ const meta = WEIGHT.get(field);
50
+ if (meta === undefined) return;
51
+ list.push({ field, label: meta.label, text: value, weight: meta.weight });
52
+ }
53
+
54
+ /**
55
+ * 把一份档案摊成「可被搜到的字段」。同一字段里的多个值各自成条,
56
+ * 这样「标签:数据质量」能精确指出是哪一条标签命中,而不是含糊地说"标签命中"。
57
+ */
58
+ export function searchableFields(roster) {
59
+ const fields = [];
60
+ if (roster === null || typeof roster !== "object") return fields;
61
+ push(fields, "id", roster.id);
62
+ push(fields, "title", roster.title);
63
+ push(fields, "description", roster.description);
64
+ push(fields, "notes", roster.notes);
65
+ for (const tag of Array.isArray(roster.tags) ? roster.tags : []) push(fields, "tags", tag);
66
+ for (const member of Array.isArray(roster.members) ? roster.members : []) {
67
+ push(fields, "memberName", member?.name);
68
+ push(fields, "memberRole", member?.role);
69
+ push(fields, "memberTitle", member?.title);
70
+ for (const skill of Array.isArray(member?.skills) ? member.skills : []) push(fields, "skills", skill);
71
+ }
72
+ const requires = roster.requires !== null && typeof roster.requires === "object" ? roster.requires : null;
73
+ if (requires !== null) {
74
+ for (const skill of Array.isArray(requires.skills) ? requires.skills : []) push(fields, "skills", skill);
75
+ for (const policy of Array.isArray(requires.toolPolicies) ? requires.toolPolicies : []) push(fields, "requires", policy);
76
+ }
77
+ return fields;
78
+ }
79
+
80
+ /**
81
+ * 一条 token 落在哪个字段上最值钱。
82
+ * 前缀命中与完全命中各加一点:搜 `data` 时 `data-quality` 应该排在 `reconcile-metadata` 前面。
83
+ */
84
+ function bestMatch(token, fields) {
85
+ let best = null;
86
+ for (const item of fields) {
87
+ const haystack = item.text.toLowerCase();
88
+ const index = haystack.indexOf(token);
89
+ if (index === -1) continue;
90
+ let score = item.weight;
91
+ if (index === 0) score += item.weight * 0.4;
92
+ if (haystack === token) score += item.weight * 0.8;
93
+ if (best === null || score > best.score) best = { score, item };
94
+ }
95
+ return best;
96
+ }
97
+
98
+ /**
99
+ * @param {string} query
100
+ * @param {{scope: "local" | "remote", roster: object, libraryId?: string|null,
101
+ * collectionId?: string|null, collectionTitle?: string|null, file?: string|null}[]} entries
102
+ * @returns {{ok: true, q: string, tokens: string[], total: number,
103
+ * matches: {scope: string, rosterId: string, title: string, description: string,
104
+ * tags: string[], members: number, libraryId: string|null,
105
+ * collectionId: string|null, collectionTitle: string|null, file: string|null,
106
+ * score: number, hits: {field: string, label: string, text: string}[]}[]}}
107
+ */
108
+ export function searchRosters(query, entries, { limit = SEARCH_LIMIT } = {}) {
109
+ const tokens = tokenize(query);
110
+ if (tokens.length === 0) return { ok: true, q: "", tokens: [], total: 0, matches: [] };
111
+
112
+ const matches = [];
113
+ for (const entry of Array.isArray(entries) ? entries : []) {
114
+ const roster = entry?.roster;
115
+ if (roster === null || typeof roster !== "object") continue;
116
+ const fields = searchableFields(roster);
117
+ let score = 0;
118
+ const hits = [];
119
+ const seen = new Set();
120
+ let complete = true;
121
+ for (const token of tokens) {
122
+ const found = bestMatch(token, fields);
123
+ if (found === null) {
124
+ complete = false;
125
+ break;
126
+ }
127
+ score += found.score;
128
+ if (!seen.has(found.item.field)) {
129
+ seen.add(found.item.field);
130
+ hits.push({ field: found.item.field, label: found.item.label, text: found.item.text });
131
+ }
132
+ }
133
+ if (!complete) continue;
134
+ const id = typeof roster.id === "string" ? roster.id : "";
135
+ // 同名匹配下,短 id 更可能是"那一个":`data-quality` 先于 `data-quality-and-observability-review`。
136
+ score += Math.max(0, 24 - id.length);
137
+ matches.push({
138
+ scope: entry.scope === "remote" ? "remote" : "local",
139
+ rosterId: id,
140
+ title: typeof roster.title === "string" ? roster.title : "",
141
+ description: typeof roster.description === "string" ? roster.description : "",
142
+ tags: Array.isArray(roster.tags) ? roster.tags.slice(0, 6) : [],
143
+ members: Array.isArray(roster.members) ? roster.members.length : 0,
144
+ libraryId: entry.libraryId ?? null,
145
+ collectionId: entry.collectionId ?? null,
146
+ collectionTitle: entry.collectionTitle ?? null,
147
+ file: entry.file ?? null,
148
+ score: Math.round(score),
149
+ hits,
150
+ });
151
+ }
152
+
153
+ matches.sort((left, right) => right.score - left.score || left.rosterId.localeCompare(right.rosterId));
154
+ return { ok: true, q: query.trim(), tokens, total: matches.length, matches: matches.slice(0, limit) };
155
+ }
package/locale/en.json CHANGED
@@ -301,5 +301,19 @@
301
301
  },
302
302
  "source.filter": "Filter this collection",
303
303
  "source.filterPlaceholder": "Filter by id, title or member",
304
- "source.filterCount": "{shown} of {total}"
304
+ "source.filterCount": "{shown} of {total}",
305
+ "lookup": {
306
+ "placeholder": "What should it do? e.g. data quality, release, review",
307
+ "hint": "Searches this machine and every subscribed source",
308
+ "clear": "Clear",
309
+ "searching": "Searching…",
310
+ "counts": "{total} matched · candidates: {local} local · {remote} remote",
311
+ "empty": "Nothing matched “{q}”. Try another wording, or check Sources for a subscription that has it.",
312
+ "failed": "Search failed: the host did not answer.",
313
+ "members": "{count} members",
314
+ "local": "local",
315
+ "remoteFrom": "from {collection}",
316
+ "open": "Open",
317
+ "pull": "Pull"
318
+ }
305
319
  }
package/locale/zh.json CHANGED
@@ -301,5 +301,19 @@
301
301
  },
302
302
  "source.filter": "在这份集合里查找",
303
303
  "source.filterPlaceholder": "按 id、标题或成员名过滤",
304
- "source.filterCount": "{shown} / {total}"
304
+ "source.filterCount": "{shown} / {total}",
305
+ "lookup": {
306
+ "placeholder": "要找什么能力?例:数据质量、发版、代码评审",
307
+ "hint": "同时搜本机与已订阅的来源",
308
+ "clear": "清除",
309
+ "searching": "正在搜索…",
310
+ "counts": "命中 {total} 份 · 候选:本机 {local} · 远端 {remote}",
311
+ "empty": "没有找到「{q}」。换个说法,或到「档案源」看看是否订阅了它。",
312
+ "failed": "搜索失败:宿主没有响应。",
313
+ "members": "{count} 名成员",
314
+ "local": "本机",
315
+ "remoteFrom": "来自 {collection}",
316
+ "open": "打开",
317
+ "pull": "拉取"
318
+ }
305
319
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stofone/dsh-agent-roster",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "多 Agent 团队的静态成员档案定义与校验层:档案库在面板里看得见、改得动,团队成员的人设、模型与工具边界固化成可校验、可版本化的文件,并在 dsh-agent-teams 创建成员时把档案桥接给运行时。",
5
5
  "type": "module",
6
6
  "main": "index.js",