@stofone/dsh-agent-roster 0.0.0-stage → 0.5.1

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/lib/tools.js ADDED
@@ -0,0 +1,601 @@
1
+ /**
2
+ * 四个模型可见工具:列档案、看成员、生成桥接调用、核对运行时。
3
+ *
4
+ * 只读优先:`roster_list` / `roster_show` / `roster_verify` 只读文件系统;
5
+ * `roster_create` 也不写任何东西——它输出的是**参数**,真正的成员创建仍由队长调用
6
+ * dsh-agent-teams 的工具完成。插件不替运行时做决定,也不在背后改别人的状态。
7
+ *
8
+ * @module dsh-agent-roster/tools
9
+ */
10
+ import { defineTool } from "@deepseek-ai/dsh-tools";
11
+
12
+ import { buildMemberBinding, describeField, summarizeBinding } from "./bridge.js";
13
+ import { LIMITS } from "./format.js";
14
+ import { resolveDirSpec } from "./home.js";
15
+ import { lossless } from "./json.js";
16
+ import { scanRosterLibrary, findRoster } from "./registry.js";
17
+ import { renderBridgePlan, renderRosterDetail, renderRosterList, renderVerifyReport } from "./render.js";
18
+ import { readTeamStates, resolveStateDir } from "./teams-state.js";
19
+ import { filterChecks, verifyBindings } from "./verify.js";
20
+
21
+ /** 工具输出统一的对象 schema:结构字段给程序,`text` 给人读。 */
22
+ const OUTPUT = { type: "object", additionalProperties: true };
23
+
24
+ /**
25
+ * 模型可读内容 = `render()` 产出的文本块;结构化对象不会自动出现在里面。
26
+ * 所以每个工具的首行都放一行 `k=v` 状态摘要:人扫一眼能懂,
27
+ * 另一个模型也不必靠中文字样去猜这次调用是成功还是失败。
28
+ */
29
+ function statusLine(tool, fields) {
30
+ const body = Object.entries(fields)
31
+ .map(([key, value]) => `${key}=${value}`)
32
+ .join(" ");
33
+ return `[${tool}] ${body}`;
34
+ }
35
+
36
+ /**
37
+ * 把工具注册进运行时。
38
+ *
39
+ * @param {import("@deepseek-ai/cordis").Context} ctx
40
+ * @param {object} config 由 index.js 的 Config 解析后的插件配置
41
+ * @param {{ version: string }} meta 插件自身元信息(`roster_list` 里显示来源)
42
+ * @returns {void}
43
+ */
44
+ export function registerRosterTools(ctx, config, meta) {
45
+ // args.path 是显式的档案库覆盖;没有就给配置里的 rosterDirs[0]。
46
+ const resolve = (exec, override) => resolvePaths(config, exec, override);
47
+ const scanAt = (rootDir) => scanRosterLibrary({ rootDir, knownToolPolicies: config.knownToolPolicies ?? null });
48
+
49
+ ctx.effect(
50
+ () =>
51
+ ctx.tools.register(
52
+ defineTool({
53
+ name: "roster_list",
54
+ description:
55
+ "列出一座本地档案库(roster 目录)里的全部成员档案,以及每份档案的校验结果。" +
56
+ "档案是静态配置:写的是成员是谁、用什么模型、受哪些工具约束。" +
57
+ "本工具只读不写;校验不通过的档案会单独列出原因,不会静默跳过。" +
58
+ "需要看某个成员的完整人设时用 roster_show。",
59
+ parameters: {
60
+ path: {
61
+ type: "string",
62
+ description:
63
+ "档案库目录;省略则用配置的 rosterDirs 第一项,留空时即全局库 $DSH_HOME/agent-roster(~/.dsh/agent-roster)",
64
+ },
65
+ },
66
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
67
+ async execute(args, exec) {
68
+ const { rosterDir, workspace } = resolve(exec, args.path);
69
+ const scan = scanAt(rosterDir);
70
+ const text = [
71
+ statusLine("roster_list", {
72
+ rootDir: rosterDir,
73
+ total: scan.stats.total,
74
+ ok: scan.stats.ok,
75
+ failed: scan.stats.failed,
76
+ members: scan.stats.members,
77
+ }),
78
+ renderRosterList(scan),
79
+ ].join("\n\n");
80
+ return lossless({
81
+ source: `@stofone/dsh-agent-roster@${meta.version}`,
82
+ workspace,
83
+ rootDir: rosterDir,
84
+ stats: scan.stats,
85
+ rosters: scan.rosters.map((roster) => ({
86
+ id: roster.id,
87
+ version: roster.version,
88
+ title: roster.title,
89
+ file: roster.file,
90
+ members: roster.members.map((member) => ({ name: member.name, role: member.role })),
91
+ tags: roster.tags,
92
+ })),
93
+ diagnostics: scan.diagnostics,
94
+ text,
95
+ });
96
+ },
97
+ presentCall: () => ({ card: "generic", title: "档案列表" }),
98
+ presentResult: (_args, result) => ({
99
+ card: "generic",
100
+ title: result.isError ? "档案列表失败" : "档案列表",
101
+ }),
102
+ }),
103
+ ),
104
+ "dsh-agent-roster: roster_list",
105
+ );
106
+
107
+ ctx.effect(
108
+ () =>
109
+ ctx.tools.register(
110
+ defineTool({
111
+ name: "roster_show",
112
+ description:
113
+ "按档案 id 或成员名查看一份档案的完整内容:每个成员的模型路由、工具策略、声明层约束、人设长度与人设开头。" +
114
+ "同时给出该成员的桥接字段清单(哪些字段运行时会真正接收、哪些只是声明)。" +
115
+ "档案 id 与成员名冲突时返回歧义列表,不替你猜。",
116
+ parameters: {
117
+ ref: {
118
+ type: "string",
119
+ required: true,
120
+ description: "档案 id(如 core-review)或其中某个成员名(如 reviewer)",
121
+ },
122
+ member: { type: "string", description: "只看这一名成员" },
123
+ path: { type: "string", description: "档案库目录;省略时同 roster_list" },
124
+ },
125
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
126
+ async execute(args, exec) {
127
+ const { rosterDir } = resolve(exec, args.path);
128
+ const scan = scanAt(rosterDir);
129
+ const hit = findRoster(scan.rosters, args.ref);
130
+ if (hit === null) {
131
+ const known = scan.rosters.map((roster) => `${roster.id}(${roster.members.map((m) => m.name).join(",")})`);
132
+ return lossless({
133
+ found: false,
134
+ text: [
135
+ statusLine("roster_show", { found: false, ref: args.ref }),
136
+ `没有档案或成员叫 "${args.ref}"。\n已知档案:${known.join(" ") || "(空)"}`,
137
+ ].join("\n\n"),
138
+ diagnostics: scan.diagnostics,
139
+ });
140
+ }
141
+ if (hit.ambiguous !== undefined) {
142
+ return lossless({
143
+ found: false,
144
+ ambiguous: hit.ambiguous.map((roster) => roster.id),
145
+ text: [
146
+ statusLine("roster_show", {
147
+ found: false,
148
+ ambiguous: hit.ambiguous.map((roster) => roster.id).join(","),
149
+ }),
150
+ `成员名 "${args.ref}" 同时出现在多份档案里:${hit.ambiguous.map((roster) => roster.id).join(", ")}。\n` +
151
+ "请改用档案 id 指定要看哪一份。",
152
+ ].join("\n\n"),
153
+ diagnostics: scan.diagnostics,
154
+ });
155
+ }
156
+ const roster = hit.roster;
157
+ const only = args.member === undefined ? undefined : [args.member];
158
+ const members = roster.members
159
+ .filter((member) => only === undefined || only.includes(member.name))
160
+ .map((member) => {
161
+ const { binding } = buildMemberBinding(roster, member);
162
+ return { member, fields: binding.fields, summary: summarizeBinding(binding) };
163
+ });
164
+ const text = [
165
+ statusLine("roster_show", {
166
+ found: true,
167
+ roster: roster.id,
168
+ version: roster.version,
169
+ members: members.length,
170
+ matchedBy: hit.via,
171
+ }),
172
+ renderRosterDetail(roster, only),
173
+ ].join("\n\n");
174
+ return lossless({
175
+ found: true,
176
+ matchedBy: hit.via,
177
+ roster: {
178
+ id: roster.id,
179
+ version: roster.version,
180
+ title: roster.title,
181
+ description: roster.description,
182
+ file: roster.file,
183
+ tags: roster.tags,
184
+ maintainers: roster.maintainers,
185
+ defaults: roster.defaults,
186
+ },
187
+ members: members.map(({ member, fields, summary }) => ({
188
+ name: member.name,
189
+ role: member.role,
190
+ description: member.description ?? "",
191
+ aliases: member.aliases ?? [],
192
+ skills: member.skills ?? [],
193
+ examples: member.examples ?? [],
194
+ provider: member.provider ?? null,
195
+ model: member.model ?? null,
196
+ reasoningEffort: member.reasoningEffort ?? null,
197
+ toolPolicy: member.toolPolicy ?? null,
198
+ systemPromptChars: member.systemPrompt.length,
199
+ binding: fields,
200
+ bindingSummary: summary,
201
+ })),
202
+ captain: roster.captain ?? null,
203
+ diagnostics: scan.diagnostics,
204
+ text,
205
+ });
206
+ },
207
+ presentCall: (args) => ({ card: "generic", title: `查看档案 ${args.ref}` }),
208
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "查看档案失败" : "档案详情" }),
209
+ }),
210
+ ),
211
+ "dsh-agent-roster: roster_show",
212
+ );
213
+
214
+ ctx.effect(
215
+ () =>
216
+ ctx.tools.register(
217
+ defineTool({
218
+ name: "roster_create",
219
+ description:
220
+ "把档案里的成员翻译成 dsh-agent-teams 的建员参数,逐个输出可直接使用的 agent_teams_add_member 调用(以及 agent_teams_create 的 plan.members 形式)。" +
221
+ "本工具不创建成员、不写入团队状态;它只把档案翻译成参数,并逐个字段标注归宿:" +
222
+ "运行时真正接收的、只作声明的、需要落部署配置的,分开写清楚。" +
223
+ "拿到参数后由你原样调用 agent_teams_add_member;成员建好后用 roster_verify 核对实际值。",
224
+ parameters: {
225
+ ref: {
226
+ type: "string",
227
+ required: true,
228
+ description: "档案 id 或成员名(成员名需在该档案中唯一)",
229
+ },
230
+ members: {
231
+ type: "array",
232
+ items: { type: "string" },
233
+ description: "只桥接这些成员;省略则整份档案",
234
+ },
235
+ path: { type: "string", description: "档案库目录;省略时同 roster_list" },
236
+ },
237
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
238
+ async execute(args, exec) {
239
+ const { rosterDir } = resolve(exec, args.path);
240
+ const scan = scanAt(rosterDir);
241
+ const hit = findRoster(scan.rosters, args.ref);
242
+ const fail = (message) =>
243
+ lossless({
244
+ ok: false,
245
+ text: [statusLine("roster_create", { ok: false, ref: args.ref }), message].join("\n\n"),
246
+ diagnostics: scan.diagnostics,
247
+ });
248
+ if (hit === null) return fail(`没有档案或成员叫 "${args.ref}"。`);
249
+ if (hit.ambiguous !== undefined) {
250
+ return fail(`成员名 "${args.ref}" 在多份档案里都有,请改用档案 id:${hit.ambiguous.map((r) => r.id).join(", ")}`);
251
+ }
252
+ const roster = hit.roster;
253
+ const wanted = Array.isArray(args.members) && args.members.length > 0 ? new Set(args.members) : null;
254
+ const selected = roster.members.filter((member) => wanted === null || wanted.has(member.name));
255
+ if (selected.length === 0) {
256
+ return fail(
257
+ `档案 ${roster.id} 里没有成员 ${[...(wanted ?? [])].join(", ")};可用:${roster.members.map((m) => m.name).join(", ")}`,
258
+ );
259
+ }
260
+ const entries = selected.map((member) => ({ member, ...buildMemberBinding(roster, member) }));
261
+ const text = [
262
+ statusLine("roster_create", {
263
+ ok: true,
264
+ roster: roster.id,
265
+ version: roster.version,
266
+ calls: entries.length,
267
+ }),
268
+ renderBridgePlan({ roster, entries }),
269
+ ].join("\n\n");
270
+ return lossless({
271
+ ok: true,
272
+ rosterId: roster.id,
273
+ rosterVersion: roster.version,
274
+ calls: entries.map((entry) => ({ tool: "agent_teams_add_member", arguments: entry.args })),
275
+ planMembers: entries.map((entry) => entry.planMember),
276
+ bindings: entries.map((entry) => ({
277
+ member: entry.member.name,
278
+ fields: entry.binding.fields,
279
+ summary: summarizeBinding(entry.binding),
280
+ })),
281
+ nextStep: "把每个 calls[].arguments 原样传给 agent_teams_add_member;随后用 roster_verify 核对运行时实际值",
282
+ diagnostics: scan.diagnostics,
283
+ text,
284
+ });
285
+ },
286
+ presentCall: (args) => ({ card: "generic", title: `桥接档案 ${args.ref}` }),
287
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "桥接失败" : "桥接参数" }),
288
+ }),
289
+ ),
290
+ "dsh-agent-roster: roster_create",
291
+ );
292
+
293
+ ctx.effect(
294
+ () =>
295
+ ctx.tools.register(
296
+ defineTool({
297
+ name: "roster_verify",
298
+ description:
299
+ "核对档案是否真的进了运行时:读取 dsh-agent-teams 的 team.json,把每个成员持久化的 provider/model/reasoningEffort/toolPolicy/executionPrompt 与档案逐字段比对,报出一致、降级、缺失、未部署。" +
300
+ "比对的是运行时实际记下的值,不是我们发出的参数,所以能发现被静默覆盖的模型或失效的工具策略。" +
301
+ "运行时状态缺失时如实降级为「无法核对」,不会假装通过。本工具只读,不写团队状态。",
302
+ parameters: {
303
+ ref: { type: "string", description: "只核对这份档案(id 或成员名);省略则核对整座档案库" },
304
+ team: { type: "string", description: "只核对这个团队 id;省略则核对全部在用团队" },
305
+ stateDir: {
306
+ type: "string",
307
+ description: "AgentTeams 状态目录;省略则用配置的 agentTeamsStateDir(默认 .agent-teams,相对当前工作区)",
308
+ },
309
+ path: { type: "string", description: "档案库目录;省略时同 roster_list" },
310
+ members: {
311
+ type: "array",
312
+ items: { type: "string" },
313
+ description: "只核对这些成员名",
314
+ },
315
+ },
316
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
317
+ async execute(args, exec) {
318
+ const { rosterDir, workspace } = resolve(exec, args.path);
319
+ const scan = scanAt(rosterDir);
320
+ let rosters = scan.rosters;
321
+ let refRosterIds = [];
322
+ if (args.ref !== undefined) {
323
+ const hit = findRoster(rosters, args.ref);
324
+ if (hit === null) {
325
+ return lossless({
326
+ ok: false,
327
+ text: [statusLine("roster_verify", { ok: false, ref: args.ref }), `没有档案或成员叫 "${args.ref}",无从核对。`].join(
328
+ "\n\n",
329
+ ),
330
+ diagnostics: scan.diagnostics,
331
+ });
332
+ }
333
+ if (hit.ambiguous !== undefined) {
334
+ return lossless({
335
+ ok: false,
336
+ text: [
337
+ statusLine("roster_verify", { ok: false, ambiguous: hit.ambiguous.map((r) => r.id).join(",") }),
338
+ `成员名 "${args.ref}" 在多份档案里都有,请改用档案 id:${hit.ambiguous.map((r) => r.id).join(", ")}`,
339
+ ].join("\n\n"),
340
+ diagnostics: scan.diagnostics,
341
+ });
342
+ }
343
+ rosters = [hit.roster];
344
+ refRosterIds = [hit.roster.id];
345
+ }
346
+
347
+ const stateDir = resolveStateDir(args.stateDir ?? config.agentTeamsStateDir ?? ".agent-teams", workspace);
348
+ const state = readTeamStates({ stateDir, teamId: args.team });
349
+ // 技能是声明层:只有宿主真的能列出技能目录时才核对,否则如实记成"无法核对"。
350
+ const knownSkills = await readKnownSkills(ctx, workspace);
351
+ let report = verifyBindings(rosters, state.teams, { knownSkills });
352
+ if (Array.isArray(args.members) && args.members.length > 0) {
353
+ report = filterChecks(report, args.members);
354
+ }
355
+ const ok = report.summary.degraded === 0 && state.available;
356
+ const text = [
357
+ statusLine("roster_verify", {
358
+ ok,
359
+ state: state.available ? "available" : "unavailable",
360
+ members: report.summary.members,
361
+ deployed: report.summary.deployed,
362
+ faithful: report.summary.faithful,
363
+ degraded: report.summary.degraded,
364
+ missing: report.summary.missing,
365
+ unmanaged: report.summary.unmanaged,
366
+ skills: `${report.summary.skillsMissing}/${report.summary.skillsDeclared}`,
367
+ }),
368
+ renderVerifyReport(report, { rosterIds: refRosterIds }),
369
+ ].join("\n\n");
370
+ return lossless({
371
+ ok,
372
+ stateDir,
373
+ teams: state.teams.map((team) => ({ id: team.id, name: team.name, phase: team.phase, file: team.file })),
374
+ summary: report.summary,
375
+ checks: report.checks.map((check) => ({
376
+ rosterId: check.rosterId,
377
+ rosterVersion: check.rosterVersion,
378
+ member: check.member,
379
+ found: check.found,
380
+ teamId: check.teamId,
381
+ faithful: check.faithful,
382
+ fields: check.binding === null ? [] : check.binding.fields,
383
+ })),
384
+ unmanaged: report.unmanaged,
385
+ skills: report.skills,
386
+ diagnostics: [...scan.diagnostics, ...state.diagnostics],
387
+ text,
388
+ });
389
+ },
390
+ presentCall: (args) => ({ card: "generic", title: args.ref === undefined ? "核对全部档案" : `核对 ${args.ref}` }),
391
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "核对失败" : "档案核对报告" }),
392
+ }),
393
+ ),
394
+ "dsh-agent-roster: roster_verify",
395
+ );
396
+
397
+ ctx.effect(
398
+ () =>
399
+ ctx.tools.register(
400
+ defineTool({
401
+ name: "roster_index",
402
+ description:
403
+ "查看订阅的公共档案源(registry):有哪些 collection、各自登记了几份档案、上次拉取时间与是否过期。" +
404
+ "给了 collection 就把那一套档案真正拉下来(只读,不写本机档案库)并列出内容。" +
405
+ "索引按 TTL 缓存(默认一小时);网络失败会沿用上次缓存,并在诊断里说明原因,不会假装“没有档案”。",
406
+ parameters: {
407
+ collection: { type: "string", description: "collection id(如 agent-roster-examples);省略则只列索引与目录" },
408
+ source: { type: "string", description: "限定某个索引源 id;省略则在所有源里找该 collection" },
409
+ refresh: { type: "boolean", description: "忽略 TTL 重新拉取(仍有 60 秒最短间隔,避免把配额烧光)" },
410
+ },
411
+ output: { schema: OUTPUT, render: (_args, value) => [{ type: "text", text: value.text }] },
412
+ async execute(args) {
413
+ const panel = await loadRemotePanel(config, { refresh: args.refresh === true });
414
+
415
+ if (typeof args.collection !== "string" || args.collection === "") {
416
+ const lines = [];
417
+ lines.push(
418
+ statusLine("roster_index", {
419
+ indexes: panel.indexes.length,
420
+ collections: panel.collections.length,
421
+ ok: panel.diagnostics.filter((item) => item.severity === "error").length === 0,
422
+ }),
423
+ );
424
+ lines.push(renderIndexSummary(panel));
425
+ return lossless({
426
+ indexes: panel.indexes,
427
+ collections: panel.collections,
428
+ diagnostics: panel.diagnostics,
429
+ text: lines.join("\n\n"),
430
+ });
431
+ }
432
+
433
+ const collection = panel.collections.find(
434
+ (item) => item.id === args.collection && (args.source === undefined || item.origin?.source === args.source),
435
+ );
436
+ if (collection === undefined) {
437
+ return lossless({
438
+ found: false,
439
+ text: [
440
+ statusLine("roster_index", { found: false, collection: args.collection }),
441
+ `没有叫 "${args.collection}" 的 collection。已知:${
442
+ panel.collections.map((item) => item.id).join(", ") || "(无)"
443
+ }`,
444
+ ].join("\n\n"),
445
+ });
446
+ }
447
+
448
+ const fetched = await fetchCollection(collection);
449
+ const scanLike = {
450
+ rootDir: `${collection.repo}/${collection.ref}/${collection.path}`,
451
+ missing: false,
452
+ entries: fetched.rosters.map((roster) => ({ id: roster.id, roster, diagnostics: [] })),
453
+ stats: {
454
+ total: fetched.rosters.length,
455
+ ok: fetched.rosters.length,
456
+ failed: 0,
457
+ members: fetched.rosters.reduce((total, roster) => total + roster.members.length, 0),
458
+ errors: 0,
459
+ warnings: 0,
460
+ infos: 0,
461
+ },
462
+ };
463
+ return lossless({
464
+ found: true,
465
+ collection: {
466
+ id: collection.id,
467
+ title: collection.title,
468
+ repo: collection.repo,
469
+ ref: collection.ref,
470
+ path: collection.path,
471
+ registered: collection.rosters,
472
+ fetched: fetched.rosters.length,
473
+ },
474
+ rosters: fetched.rosters.map((roster) => ({
475
+ id: roster.id,
476
+ version: roster.version,
477
+ title: roster.title,
478
+ members: roster.members.map((member) => member.name),
479
+ origin: roster.origin,
480
+ })),
481
+ diagnostics: [...panel.diagnostics, ...fetched.diagnostics],
482
+ text: [
483
+ statusLine("roster_index", {
484
+ collection: collection.id,
485
+ registered: collection.rosters.length,
486
+ fetched: fetched.rosters.length,
487
+ }),
488
+ renderRosterList(scanLike),
489
+ fetched.diagnostics.length === 0 ? "" : renderDiagnostics(fetched.diagnostics),
490
+ ]
491
+ .filter(Boolean)
492
+ .join("\n\n"),
493
+ });
494
+ },
495
+ presentCall: (args) => ({
496
+ card: "generic",
497
+ title: args.collection === undefined ? "公共档案源" : `公共档案源 ${args.collection}`,
498
+ }),
499
+ presentResult: (_args, result) => ({ card: "generic", title: result.isError ? "读取公共档案源失败" : "公共档案源" }),
500
+ }),
501
+ ),
502
+ "dsh-agent-roster: roster_index",
503
+ );
504
+ }
505
+
506
+ /**
507
+ * 索引与目录的文字摘要(`roster_index` 不带 collection 时用)。
508
+ *
509
+ * @param {object} panel `loadRemotePanel` 的产物
510
+ * @returns {string}
511
+ */
512
+ function renderIndexSummary(panel) {
513
+ const lines = [];
514
+ if (panel.indexes.length === 0) {
515
+ lines.push("没有配置任何索引源(设置里的 indexes 为空时只用官方源)。");
516
+ return lines.join("\n");
517
+ }
518
+ lines.push("索引源");
519
+ for (const index of panel.indexes) {
520
+ const state = index.ok ? (index.stale ? "缓存(拉取失败)" : "正常") : "不可用";
521
+ lines.push(` ${index.label} · ${state} · collection ${index.collections} · 拉取于 ${index.fetchedAt ?? "-"}`);
522
+ }
523
+ lines.push("");
524
+ if (panel.collections.length === 0) {
525
+ lines.push("目录里还没有 collection。");
526
+ } else {
527
+ lines.push("collection");
528
+ for (const collection of panel.collections) {
529
+ lines.push(
530
+ ` ${collection.id} · ${collection.title} · ${collection.rosters.length} 份档案 · ${collection.repo}@${collection.ref}/${collection.path}`,
531
+ );
532
+ if (collection.description !== "") lines.push(` ${collection.description}`);
533
+ }
534
+ }
535
+ const problems = (panel.diagnostics ?? []).filter((item) => item.severity !== "info");
536
+ if (problems.length > 0) lines.push("", renderDiagnostics(problems));
537
+ return lines.join("\n");
538
+ }
539
+
540
+ /**
541
+ * 读宿主技能目录(可选服务)。
542
+ *
543
+ * 拿不到就返回 `null`:调用方会把技能核对标成"无法核对",而不是把"没读到"说成"都没装"。
544
+ *
545
+ * @param {import("@deepseek-ai/cordis").Context} ctx
546
+ * @param {string} workspace
547
+ * @returns {Promise<string[] | null>}
548
+ */
549
+ async function readKnownSkills(ctx, workspace) {
550
+ try {
551
+ const skills = ctx.get?.("skills") ?? ctx.skills;
552
+ if (skills === null || skills === undefined || typeof skills.list !== "function") return null;
553
+ const list = await skills.list({ cwd: workspace });
554
+ if (!Array.isArray(list)) return null;
555
+ return list
556
+ .map((item) => (item !== null && typeof item === "object" ? item.name : undefined))
557
+ .filter((name) => typeof name === "string" && name !== "");
558
+ } catch {
559
+ return null;
560
+ }
561
+ }
562
+
563
+ /**
564
+ * 解析档案库与工作区路径。
565
+ *
566
+ * 档案库默认**全局**:`<DSH_HOME>/agent-roster`,不随工作区变化。只有配置里写了相对路径
567
+ * (或调用时传了相对 `path`)才按会话工作区解析——那是显式例外,不是默认行为。
568
+ * 拿不到会话时退回进程工作目录,并在诊断里说明。
569
+ *
570
+ * 工具参数里的 `path` 是**显式覆盖**:给了就只信它,不再回落配置,避免"传了参数却
571
+ * 静默读默认目录"这种最难查的行为(真机验证里正是这个 bug)。
572
+ *
573
+ * @param {object} config
574
+ * @param {{ agent?: object }} exec
575
+ * @param {string | undefined} override 工具参数里的档案库目录
576
+ * @returns {{ rosterDir: string, workspace: string, workspaceKnown: boolean, overridden: boolean }}
577
+ */
578
+ export function resolvePaths(config, exec, override) {
579
+ const fromSession = exec?.agent?.session?.header?.cwd;
580
+ const workspaceKnown = typeof fromSession === "string" && fromSession !== "";
581
+ const workspace = workspaceKnown ? fromSession : process.cwd();
582
+ const hasOverride = typeof override === "string" && override !== "";
583
+ const configured = hasOverride
584
+ ? override
585
+ : Array.isArray(config.rosterDirs) && config.rosterDirs.length > 0
586
+ ? config.rosterDirs[0]
587
+ : "";
588
+ const rosterDir = resolveDirSpec(configured, workspace);
589
+ return { rosterDir, workspace, workspaceKnown, overridden: hasOverride };
590
+ }
591
+
592
+ /** 供 CLI 与测试复用:把诊断折成一行摘要。 */
593
+ export function summarizeDiagnostics(diagnostics) {
594
+ const list = diagnostics ?? [];
595
+ const errors = list.filter((item) => item.severity === "error").length;
596
+ const warnings = list.filter((item) => item.severity === "warn").length;
597
+ return { errors, warnings, total: list.length, maxChars: LIMITS.descriptionChars };
598
+ }
599
+
600
+ /** 供 CLI 复用的字段说明行。 */
601
+ export { describeField };