@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/CHANGELOG.md +157 -0
- package/LICENSE +21 -0
- package/README.md +290 -3
- package/README.zh.md +282 -0
- package/bin/cli.mjs +325 -0
- package/client.js +1950 -0
- package/cordis.patch.yml +9 -0
- package/examples/core-review/roster.yaml +82 -0
- package/examples/research/roster.yaml +42 -0
- package/icon.svg +11 -0
- package/index/registry.json +97 -0
- package/index.js +298 -0
- package/lib/bridge.js +233 -0
- package/lib/config-view.js +76 -0
- package/lib/format.js +223 -0
- package/lib/home.js +108 -0
- package/lib/http.js +122 -0
- package/lib/json.js +89 -0
- package/lib/library.js +161 -0
- package/lib/parse.js +148 -0
- package/lib/registry.js +285 -0
- package/lib/remote.js +623 -0
- package/lib/render.js +343 -0
- package/lib/serialize.js +92 -0
- package/lib/service.js +421 -0
- package/lib/teams-state.js +146 -0
- package/lib/tools.js +601 -0
- package/lib/validate.js +747 -0
- package/lib/verify.js +164 -0
- package/lib/write.js +290 -0
- package/locale/en.json +245 -0
- package/locale/zh.json +245 -0
- package/package.json +98 -4
- package/schema/roster.v1.schema.json +193 -0
package/lib/render.js
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 人读渲染:工具输出与 CLI 共用同一套文本,避免"工具里一个样子、CI 里另一个样子"。
|
|
3
|
+
*
|
|
4
|
+
* 输出一律是纯文本(不是 Markdown 表格):模型读、人在终端读都成立,
|
|
5
|
+
* 也不会因为成员名里的字符破坏表格。JSON 视图由 `toJson` 单独提供。
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-agent-roster/render
|
|
8
|
+
*/
|
|
9
|
+
import { BINDING_STATUS, describeField, summarizeBinding } from "./bridge.js";
|
|
10
|
+
import { LIMITS } from "./format.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 诊断列表 → 文本块。错误在前、按文件分组,超出上限时明确写出省略了多少条。
|
|
14
|
+
*
|
|
15
|
+
* @param {object[]} diagnostics
|
|
16
|
+
* @param {{ limit?: number, indent?: string }} [options]
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
export function renderDiagnostics(diagnostics, options = {}) {
|
|
20
|
+
const list = (diagnostics ?? []).filter((item) => item !== null && typeof item === "object");
|
|
21
|
+
if (list.length === 0) return "";
|
|
22
|
+
const limit = options.limit ?? 40;
|
|
23
|
+
const indent = options.indent ?? " ";
|
|
24
|
+
const severityRank = { error: 0, warn: 1, info: 2 };
|
|
25
|
+
const sorted = list
|
|
26
|
+
.slice()
|
|
27
|
+
.sort((a, b) => (severityRank[a.severity] ?? 9) - (severityRank[b.severity] ?? 9));
|
|
28
|
+
const shown = sorted.slice(0, limit);
|
|
29
|
+
const lines = [];
|
|
30
|
+
for (const item of shown) {
|
|
31
|
+
// path/file 都可能是 null("这条诊断不属于任何字段/文件"),null 不能落成字面量 "null"。
|
|
32
|
+
const path = item.path === null ? "" : String(item.path ?? "");
|
|
33
|
+
const fileText = item.file === null ? "" : String(item.file ?? "");
|
|
34
|
+
const where = path === "" ? "" : ` ${path}`;
|
|
35
|
+
const file = fileText === "" ? "" : `${fileText}: `;
|
|
36
|
+
lines.push(`${indent}${item.severity}${where} ${file}${item.reason}${item.code ? ` [${item.code}]` : ""}`);
|
|
37
|
+
}
|
|
38
|
+
if (sorted.length > shown.length) {
|
|
39
|
+
lines.push(`${indent}… 还有 ${sorted.length - shown.length} 条诊断未显示`);
|
|
40
|
+
}
|
|
41
|
+
return lines.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 档案库总览。
|
|
46
|
+
*
|
|
47
|
+
* @param {{ rootDir: string, entries: object[], stats: object }} scan
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function renderRosterList(scan) {
|
|
51
|
+
const { entries, stats, rootDir } = scan;
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`档案库 ${rootDir}`);
|
|
54
|
+
if (entries.length === 0) {
|
|
55
|
+
lines.push("");
|
|
56
|
+
lines.push(scan.missing === true ? "(目录尚未创建:保存第一份档案时会自动建)" : "(空:没有任何 roster 目录)");
|
|
57
|
+
lines.push("目录约定:<root>/<roster-id>/roster.{json,yaml,yml}");
|
|
58
|
+
return lines.join("\n");
|
|
59
|
+
}
|
|
60
|
+
lines.push(`共 ${stats.total} 个目录 · 通过 ${stats.ok} · 失败 ${stats.failed}`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
lines.push("ID 版本 成员 状态 标题");
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
const roster = entry.roster;
|
|
65
|
+
if (roster === null) {
|
|
66
|
+
lines.push(`${pad(entry.id, 21)}${pad("-", 10)}${pad("-", 5)}${pad("失败", 9)}(无有效档案)`);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
lines.push(
|
|
70
|
+
`${pad(roster.id, 21)}${pad(roster.version, 10)}${pad(String(roster.members.length), 5)}${pad("通过", 9)}${roster.title}`,
|
|
71
|
+
);
|
|
72
|
+
lines.push(` ${roster.members.map((member) => member.name).join(", ")}`);
|
|
73
|
+
}
|
|
74
|
+
const failed = scan.diagnostics.filter((item) => item.severity === "error");
|
|
75
|
+
if (failed.length > 0) {
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("失败原因:");
|
|
78
|
+
lines.push(renderDiagnostics(failed, { limit: 20 }));
|
|
79
|
+
}
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 单份档案详情。
|
|
85
|
+
*
|
|
86
|
+
* @param {object} roster
|
|
87
|
+
* @param {string[]} [onlyMembers]
|
|
88
|
+
* @returns {string}
|
|
89
|
+
*/
|
|
90
|
+
export function renderRosterDetail(roster, onlyMembers) {
|
|
91
|
+
const wanted =
|
|
92
|
+
Array.isArray(onlyMembers) && onlyMembers.length > 0 ? roster.members.filter((m) => onlyMembers.includes(m.name)) : roster.members;
|
|
93
|
+
const lines = [];
|
|
94
|
+
lines.push(`roster:${roster.id}@${roster.version} ${roster.title}`);
|
|
95
|
+
if (roster.description !== "") lines.push(` ${roster.description}`);
|
|
96
|
+
const meta = [];
|
|
97
|
+
if (roster.license !== "") meta.push(`license=${roster.license}`);
|
|
98
|
+
if (roster.maintainers.length > 0) meta.push(`maintainers=${roster.maintainers.join(", ")}`);
|
|
99
|
+
if (roster.tags.length > 0) meta.push(`tags=${roster.tags.join(", ")}`);
|
|
100
|
+
if (meta.length > 0) lines.push(` ${meta.join(" ")}`);
|
|
101
|
+
if (roster.captain !== null && roster.captain !== undefined) {
|
|
102
|
+
lines.push(...renderCaptainSection(roster.captain));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const defaults = Object.entries(roster.defaults ?? {});
|
|
106
|
+
if (defaults.length > 0) {
|
|
107
|
+
lines.push("");
|
|
108
|
+
lines.push("defaults(成员未写该字段时继承)");
|
|
109
|
+
for (const [key, value] of defaults) {
|
|
110
|
+
lines.push(` ${pad(key, 16)}${literal(value)}`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (wanted.length === 0) {
|
|
115
|
+
lines.push("");
|
|
116
|
+
lines.push(`(档案里没有成员:${(onlyMembers ?? []).join(", ")})`);
|
|
117
|
+
return lines.join("\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push(`成员 ${wanted.length} 个`);
|
|
122
|
+
for (const member of wanted) {
|
|
123
|
+
lines.push("");
|
|
124
|
+
lines.push(`▸ ${member.name} — ${member.role}`);
|
|
125
|
+
if (member.provider !== undefined || member.model !== undefined) {
|
|
126
|
+
const route = [member.provider ?? "(默认 provider)", member.model ?? "(默认 model)"].join(" / ");
|
|
127
|
+
lines.push(` model ${route}${member.inherited?.includes("model") ? " (继承 defaults)" : ""}`);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push(" model 未指定 → 运行时沿用队长模型");
|
|
130
|
+
}
|
|
131
|
+
if (member.reasoningEffort !== undefined) lines.push(` reasoningEffort ${member.reasoningEffort}`);
|
|
132
|
+
if (member.toolPolicy !== undefined) lines.push(` toolPolicy ${member.toolPolicy}(必须是部署已声明的策略 id)`);
|
|
133
|
+
if (member.fallback !== undefined) lines.push(` fallback ${member.fallback.provider} / ${member.fallback.model}(需落部署配置)`);
|
|
134
|
+
if (member.toolScope !== undefined) lines.push(` toolScope 仅声明:${describeToolScope(member.toolScope)}`);
|
|
135
|
+
if (member.maxTokens !== undefined) lines.push(` maxTokens ${member.maxTokens}(仅声明)`);
|
|
136
|
+
if (member.tags.length > 0) lines.push(` tags ${member.tags.join(", ")}`);
|
|
137
|
+
if (member.description !== undefined && member.description !== "") {
|
|
138
|
+
lines.push(` description ${member.description}`);
|
|
139
|
+
}
|
|
140
|
+
if (member.aliases !== undefined && member.aliases.length > 0) {
|
|
141
|
+
lines.push(` aliases ${member.aliases.join(", ")}(改名前的旧名,核对时仍认作同一成员)`);
|
|
142
|
+
}
|
|
143
|
+
if (member.skills !== undefined && member.skills.length > 0) {
|
|
144
|
+
lines.push(` skills ${member.skills.join(", ")}(仅声明:运行时不会自动加载技能)`);
|
|
145
|
+
}
|
|
146
|
+
if (member.examples !== undefined && member.examples.length > 0) {
|
|
147
|
+
lines.push(` examples ${member.examples.length} 条典型请求`);
|
|
148
|
+
for (const example of member.examples) lines.push(` · ${example}`);
|
|
149
|
+
}
|
|
150
|
+
if (member.notes !== "") lines.push(` notes ${member.notes}`);
|
|
151
|
+
lines.push(` systemPrompt ${member.systemPrompt.length} 字符 / 上限 ${LIMITS.systemPromptChars}`);
|
|
152
|
+
lines.push(` ${firstLines(member.systemPrompt, 2, 160)}`);
|
|
153
|
+
}
|
|
154
|
+
return lines.join("\n");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 桥接计划:把每个成员翻译成一条 `agent_teams_add_member` 调用,并标注每个字段的归宿。
|
|
159
|
+
*
|
|
160
|
+
* @param {{ roster: object, entries: object[], planMembers: object[] }} plan
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function renderBridgePlan(plan) {
|
|
164
|
+
const { roster, entries } = plan;
|
|
165
|
+
const lines = [];
|
|
166
|
+
lines.push(`桥接计划 roster:${roster.id}@${roster.version} → ${entries.length} 个成员`);
|
|
167
|
+
lines.push("");
|
|
168
|
+
lines.push("本插件不创建成员:下面的参数请由队长原样传给 dsh-agent-teams 的 agent_teams_add_member。");
|
|
169
|
+
lines.push("(新团队也可以把这些成员放进 agent_teams_create 的 plan.members。)");
|
|
170
|
+
for (const [index, entry] of entries.entries()) {
|
|
171
|
+
lines.push("");
|
|
172
|
+
lines.push(`${index + 1}) ${entry.member.name} — ${entry.member.role}`);
|
|
173
|
+
lines.push(` agent_teams_add_member(${JSON.stringify(entry.args, null, 2).replace(/\n/g, "\n ")})`);
|
|
174
|
+
lines.push("");
|
|
175
|
+
for (const field of entry.binding.fields) {
|
|
176
|
+
lines.push(` ${describeField(field)}`);
|
|
177
|
+
}
|
|
178
|
+
const summary = summarizeBinding(entry.binding);
|
|
179
|
+
const awaiting = summary.counts.pending ?? 0;
|
|
180
|
+
lines.push("");
|
|
181
|
+
lines.push(` 待运行时回执的字段:${awaiting};仅声明:${summary.counts["not-carried"] ?? 0};需落配置:${summary.counts["config-required"] ?? 0}`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
lines.push("成员创建完成后,用 roster_verify 比对 team.json 里的实际值;一致才算档案真正生效。");
|
|
185
|
+
return lines.join("\n");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 核对报告。
|
|
190
|
+
*
|
|
191
|
+
* @param {object} report `verifyBindings` 的结果
|
|
192
|
+
* @param {{ rosterIds?: string[] }} [options]
|
|
193
|
+
* @returns {string}
|
|
194
|
+
*/
|
|
195
|
+
export function renderVerifyReport(report, options = {}) {
|
|
196
|
+
const lines = [];
|
|
197
|
+
const rosterIds = new Set(options.rosterIds ?? []);
|
|
198
|
+
const checks = rosterIds.size === 0 ? report.checks : report.checks.filter((check) => rosterIds.has(check.rosterId));
|
|
199
|
+
|
|
200
|
+
if (checks.length === 0) {
|
|
201
|
+
lines.push("没有可核对的成员(档案为空,或指定成员不在档案里)。");
|
|
202
|
+
return lines.join("\n");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const byRoster = new Map();
|
|
206
|
+
for (const check of checks) {
|
|
207
|
+
const key = `${check.rosterId}@${check.rosterVersion}`;
|
|
208
|
+
if (!byRoster.has(key)) byRoster.set(key, []);
|
|
209
|
+
byRoster.get(key).push(check);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
for (const [key, group] of byRoster) {
|
|
213
|
+
const faithful = group.filter((check) => check.found && check.faithful).length;
|
|
214
|
+
const found = group.filter((check) => check.found).length;
|
|
215
|
+
lines.push(`roster:${key} 成员 ${group.length} · 已部署 ${found} · 一致 ${faithful}`);
|
|
216
|
+
for (const check of group) {
|
|
217
|
+
if (!check.found) {
|
|
218
|
+
lines.push("");
|
|
219
|
+
lines.push(`▸ ${check.member} 未在团队中部署(档案定义了,运行时没有同名成员)`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
lines.push("");
|
|
223
|
+
const verdict = check.faithful ? "一致" : "降级";
|
|
224
|
+
lines.push(`▸ ${check.member} @ ${check.teamName} ${verdict}`);
|
|
225
|
+
if (typeof check.matchedAs === "string" && check.matchedAs !== check.member) {
|
|
226
|
+
lines.push(` (运行时以旧名 "${check.matchedAs}" 记录,来自 aliases)`);
|
|
227
|
+
}
|
|
228
|
+
for (const field of check.binding.fields) {
|
|
229
|
+
if (field.status === "pending") continue;
|
|
230
|
+
lines.push(` ${describeField(field)}`);
|
|
231
|
+
}
|
|
232
|
+
if (!check.faithful) {
|
|
233
|
+
const summary = check.summary;
|
|
234
|
+
lines.push(
|
|
235
|
+
` → 降级字段:覆盖 ${summary.counts.overridden ?? 0} · 缺失 ${summary.counts.omitted ?? 0} · 无法核对 ${summary.counts.unconfirmed ?? 0}`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
lines.push("");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (report.unmanaged.length > 0) {
|
|
243
|
+
lines.push(`另有 ${report.unmanaged.length} 个成员不在任何档案里(运行时直接创建的):`);
|
|
244
|
+
for (const item of report.unmanaged) {
|
|
245
|
+
const route = [item.provider, item.model].filter((part) => part !== "").join("/");
|
|
246
|
+
lines.push(` · ${item.name} @ ${item.teamName}${route === "" ? "" : ` ${route}`}`);
|
|
247
|
+
}
|
|
248
|
+
lines.push("");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const summary = report.summary;
|
|
252
|
+
lines.push(
|
|
253
|
+
`汇总:一致 ${summary.faithful} · 降级 ${summary.degraded} · 未部署 ${summary.missing} · 无档案 ${summary.unmanaged}`,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const skills = (report.skills ?? []).filter(
|
|
257
|
+
(item) => rosterIds.size === 0 || rosterIds.has(item.rosterId),
|
|
258
|
+
);
|
|
259
|
+
if (skills.length > 0) {
|
|
260
|
+
lines.push("");
|
|
261
|
+
lines.push("技能声明核对(声明层:运行时不会据此加载技能)");
|
|
262
|
+
for (const item of skills) {
|
|
263
|
+
const state = !item.checked
|
|
264
|
+
? "无法核对(宿主没有提供技能目录)"
|
|
265
|
+
: item.missing.length > 0
|
|
266
|
+
? `未安装:${item.missing.join(", ")}`
|
|
267
|
+
: "全部已安装";
|
|
268
|
+
lines.push(` · ${item.member} [${item.rosterId}] ${item.declared.join(", ")} → ${state}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return lines.join("\n");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 队长段:说明"能声明什么、真要生效要改哪里"。
|
|
276
|
+
*
|
|
277
|
+
* 队长不是成员——它就是创建团队的那个会话。这里绝不写成"档案已配置队长",
|
|
278
|
+
* 否则使用者会以为改档案就能换队长模型。
|
|
279
|
+
*
|
|
280
|
+
* @param {object} captain 已校验的 captain 段
|
|
281
|
+
* @returns {string[]}
|
|
282
|
+
*/
|
|
283
|
+
export function renderCaptainSection(captain) {
|
|
284
|
+
const lines = [];
|
|
285
|
+
lines.push("");
|
|
286
|
+
lines.push(`队长 captain:${captain.name}(声明层:档案不创建队长,也不改写它的会话)`);
|
|
287
|
+
if (captain.description !== "") lines.push(` ${captain.description}`);
|
|
288
|
+
if (captain.provider !== undefined || captain.model !== undefined) {
|
|
289
|
+
lines.push(` 路由声明 ${captain.provider ?? "?"} / ${captain.model ?? "?"}(需落到 profile 的 agent-default-model)`);
|
|
290
|
+
}
|
|
291
|
+
if (captain.personaPrefix !== "") lines.push(` personaPrefix ${firstLines(captain.personaPrefix, 2, 160)}`);
|
|
292
|
+
if (captain.systemPrompt !== "") {
|
|
293
|
+
lines.push(` systemPrompt ${captain.systemPrompt.length} 字符(追加给队长的行为约定,需拼进团队目标的描述里)`);
|
|
294
|
+
}
|
|
295
|
+
lines.push(" 生效方式 agent-teams 没有 captain 成员行:队长=创建团队的那个会话");
|
|
296
|
+
lines.push(" 1) 路由 → profile 的 agent-default-model(或会话内切模型)");
|
|
297
|
+
lines.push(" 2) 人格 → agent preset 里 @deepseek-ai/dsh-persona 的 prefix/suffix");
|
|
298
|
+
lines.push(" 3) 追加约定 → 写进 agent_teams_create 的 description/goal");
|
|
299
|
+
if (captain.provider !== undefined && captain.model !== undefined) {
|
|
300
|
+
lines.push("");
|
|
301
|
+
lines.push(" profile 片段(cordis.patch.yml):");
|
|
302
|
+
lines.push(" - id: agent-default-model");
|
|
303
|
+
lines.push(' name: "@deepseek-ai/dsh-agent-default-model"');
|
|
304
|
+
lines.push(" config:");
|
|
305
|
+
lines.push(` provider: ${captain.provider}`);
|
|
306
|
+
lines.push(` model: ${captain.model}`);
|
|
307
|
+
}
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 状态标签,供 UI 与报告共用。 */
|
|
312
|
+
export function statusLabel(status) {
|
|
313
|
+
return BINDING_STATUS[status] ?? status;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function pad(text, width) {
|
|
317
|
+
const value = String(text);
|
|
318
|
+
if (value.length >= width) return `${value} `;
|
|
319
|
+
return value + " ".repeat(width - value.length);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function literal(value) {
|
|
323
|
+
if (typeof value === "string") return value;
|
|
324
|
+
if (value === null) return "null";
|
|
325
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
326
|
+
return String(value);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function describeToolScope(scope) {
|
|
330
|
+
const parts = [];
|
|
331
|
+
if (Array.isArray(scope.allow) && scope.allow.length > 0) parts.push(`allow(${scope.allow.length})`);
|
|
332
|
+
if (Array.isArray(scope.deny) && scope.deny.length > 0) parts.push(`deny(${scope.deny.length})`);
|
|
333
|
+
return parts.length === 0 ? "空" : parts.join(" ");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function firstLines(text, maxLines, maxChars) {
|
|
337
|
+
const value = String(text ?? "")
|
|
338
|
+
.trim()
|
|
339
|
+
.replace(/\r\n/g, "\n");
|
|
340
|
+
const lines = value.split("\n").slice(0, maxLines).join(" / ");
|
|
341
|
+
const clipped = lines.length > maxChars ? `${lines.slice(0, maxChars)}…` : lines;
|
|
342
|
+
return clipped === "" ? "(空)" : clipped;
|
|
343
|
+
}
|
package/lib/serialize.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把一份**已校验**的档案还原成可落盘的文档文本。
|
|
3
|
+
*
|
|
4
|
+
* 用在"从公共目录拉取到本机"这条路上:远端拉下来的是对象,落盘要的是文件。
|
|
5
|
+
* 选择 JSON 而不是 YAML 的理由很实际——JSON 不会因为手写缩进、引号、多行块的
|
|
6
|
+
* 细微差异而在下一次拉取时被误判成"用户改过"。YAML 那份留给用户自己写。
|
|
7
|
+
*
|
|
8
|
+
* 只写**档案语义字段**:`inherited`/`origin`/`file` 是校验与投影过程的副产物,
|
|
9
|
+
* 写进文件会变成"下一版校验器不认识的多余键"。
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-agent-roster/serialize
|
|
12
|
+
*/
|
|
13
|
+
import { ROSTER_KIND } from "./format.js";
|
|
14
|
+
|
|
15
|
+
/** 档案顶层字段的落盘顺序(顺序稳定,diff 才好看)。 */
|
|
16
|
+
const TOP_LEVEL = [
|
|
17
|
+
["kind", (roster) => (roster.kind === ROSTER_KIND ? ROSTER_KIND : ROSTER_KIND)],
|
|
18
|
+
["roster", (roster) => roster.id],
|
|
19
|
+
["version", (roster) => roster.version],
|
|
20
|
+
["title", (roster) => roster.title],
|
|
21
|
+
["description", (roster) => roster.description],
|
|
22
|
+
["license", (roster) => roster.license],
|
|
23
|
+
["maintainers", (roster) => roster.maintainers],
|
|
24
|
+
["tags", (roster) => roster.tags],
|
|
25
|
+
["defaults", (roster) => roster.defaults],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** 成员字段顺序;值为空则整键省略,避免生成一堆 `"notes": ""`。 */
|
|
29
|
+
const MEMBER_FIELDS = [
|
|
30
|
+
"name",
|
|
31
|
+
"role",
|
|
32
|
+
"title",
|
|
33
|
+
"description",
|
|
34
|
+
"aliases",
|
|
35
|
+
"skills",
|
|
36
|
+
"examples",
|
|
37
|
+
"systemPrompt",
|
|
38
|
+
"tags",
|
|
39
|
+
"notes",
|
|
40
|
+
"provider",
|
|
41
|
+
"model",
|
|
42
|
+
"reasoningEffort",
|
|
43
|
+
"toolPolicy",
|
|
44
|
+
"toolScope",
|
|
45
|
+
"fallback",
|
|
46
|
+
"maxTokens",
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
function isEmpty(value) {
|
|
50
|
+
if (value === undefined || value === null) return true;
|
|
51
|
+
if (typeof value === "string") return value === "";
|
|
52
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
53
|
+
if (typeof value === "object") return Object.keys(value).length === 0;
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pick(source, fields) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const field of fields) {
|
|
60
|
+
const value = source?.[field];
|
|
61
|
+
if (!isEmpty(value)) out[field] = value;
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 档案对象 → 落盘文本(JSON,两个空格缩进,末尾带换行)。
|
|
68
|
+
*
|
|
69
|
+
* @param {object} roster 已校验的档案(`validateRoster` 的产物)
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function rosterDocumentText(roster) {
|
|
73
|
+
const document = {};
|
|
74
|
+
for (const [key, read] of TOP_LEVEL) {
|
|
75
|
+
const value = read(roster);
|
|
76
|
+
if (!isEmpty(value)) document[key] = value;
|
|
77
|
+
}
|
|
78
|
+
document.members = (roster.members ?? []).map((member) => pick(member, MEMBER_FIELDS));
|
|
79
|
+
if (!isEmpty(roster.captain)) document.captain = pick(roster.captain, CAPTAIN_FIELDS);
|
|
80
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 队长段允许出现的字段。 */
|
|
84
|
+
const CAPTAIN_FIELDS = [
|
|
85
|
+
"name",
|
|
86
|
+
"description",
|
|
87
|
+
"provider",
|
|
88
|
+
"model",
|
|
89
|
+
"reasoningEffort",
|
|
90
|
+
"personaPrefix",
|
|
91
|
+
"systemPrompt",
|
|
92
|
+
];
|