@stofone/dsh-agent-roster 0.6.2 → 0.7.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 +64 -1
- package/LICENSE +21 -21
- package/README.md +370 -319
- package/README.zh.md +57 -8
- package/index.js +2 -2
- package/lib/bridge.js +89 -10
- package/lib/capabilities.js +434 -0
- package/lib/diff.js +216 -0
- package/lib/digest.js +65 -0
- package/lib/library.js +5 -0
- package/lib/registry.js +127 -4
- package/lib/render.js +238 -7
- package/lib/tools.js +248 -23
- package/lib/verify.js +135 -10
- package/package.json +4 -1
- package/schema/capabilities.v1.schema.json +124 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 能力要求(`capabilities.yaml`):与档案同目录的**兄弟文件**,只写"要用到哪些能力",
|
|
3
|
+
* 不承载任何能力本身。
|
|
4
|
+
*
|
|
5
|
+
* 为什么不在 `roster.yaml` 里加字段:档案格式是严格闭集(未知键 = 错误),
|
|
6
|
+
* 而且上游只会消费那 17 个已知字段。能力要求属于"部署侧的事实",不是"成员是谁",
|
|
7
|
+
* 所以另放一个文件,由本插件自己读、自己校验。
|
|
8
|
+
*
|
|
9
|
+
* 三条纪律(都是踩过的坑换来的):
|
|
10
|
+
*
|
|
11
|
+
* 1. **只声明能核对的**。目前能核对两类:技能是否装在技能目录里(`ctx.get("skills")`),
|
|
12
|
+
* toolPolicy id 是否在部署声明里(配置 `knownToolPolicies`)。模型路由、插件版本、
|
|
13
|
+
* 凭据指针一律不写——它们在这个上下文里没有证据源,写进来只会再造一次假绿。
|
|
14
|
+
* 2. **缺失的语义由 `onMissing` 决定,默认 `refuse`**:拒绝出参数,而不是"先建了再说"。
|
|
15
|
+
* 静默降级正是 `systemPrompt` 假绿的翻版。
|
|
16
|
+
* 3. **核对不到 ≠ 缺失**。没有证据源时状态是 `unchecked`,既不翻红也不冒充通过。
|
|
17
|
+
*
|
|
18
|
+
* @module dsh-agent-roster/capabilities
|
|
19
|
+
*/
|
|
20
|
+
import { diagnostic } from "./parse.js";
|
|
21
|
+
|
|
22
|
+
/** 能力要求文档的格式标识。 */
|
|
23
|
+
export const CAPABILITIES_KIND = "dsh-agent-roster-capabilities/v1";
|
|
24
|
+
|
|
25
|
+
/** 兄弟文件的候选文件名,按优先级取第一个。 */
|
|
26
|
+
export const CAPABILITIES_FILE_NAMES = ["capabilities.yaml", "capabilities.yml", "capabilities.json"];
|
|
27
|
+
|
|
28
|
+
/** 能力缺失时的行为。`refuse` 是默认值:缺能力就不出参数。 */
|
|
29
|
+
export const ON_MISSING = /** @type {const} */ (["refuse", "degraded"]);
|
|
30
|
+
export const DEFAULT_ON_MISSING = "refuse";
|
|
31
|
+
|
|
32
|
+
/** 顶层字段闭集。 */
|
|
33
|
+
const ROOT_FIELDS = new Set(["kind", "roster", "onMissing", "asOf", "requires", "notes"]);
|
|
34
|
+
/** `requires` 的字段闭集。 */
|
|
35
|
+
const REQUIRES_FIELDS = new Set(["skills", "toolPolicies"]);
|
|
36
|
+
|
|
37
|
+
export const LIMITS = { requirements: 32, nameChars: 64, reasonChars: 200, notesChars: 500 };
|
|
38
|
+
|
|
39
|
+
/** 与 format.js 同一口径的 kebab-case id 模式。 */
|
|
40
|
+
export const ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,63}$/;
|
|
41
|
+
/** 技能名允许大写与下划线(DSH 技能目录不强制 kebab-case)。 */
|
|
42
|
+
export const SKILL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
43
|
+
/** `asOf` 只接受日期,不接受时间戳:能力清单是按天核的,写时分秒会制造假精度。 */
|
|
44
|
+
const AS_OF_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 校验一份能力要求文档(纯函数、零 IO)。
|
|
48
|
+
*
|
|
49
|
+
* @param {unknown} value 已解析的对象树
|
|
50
|
+
* @param {{ file?: string, expectedId?: string | null }} [options]
|
|
51
|
+
* @returns {{ ok: boolean, capabilities: object | null, diagnostics: object[] }}
|
|
52
|
+
*/
|
|
53
|
+
export function validateCapabilities(value, options = {}) {
|
|
54
|
+
const file = String(options.file ?? "");
|
|
55
|
+
const expectedId =
|
|
56
|
+
typeof options.expectedId === "string" && options.expectedId !== "" ? options.expectedId : null;
|
|
57
|
+
const diagnostics = [];
|
|
58
|
+
const report = (severity, code, path, reason) =>
|
|
59
|
+
diagnostics.push(diagnostic(severity, code, path, reason, file));
|
|
60
|
+
|
|
61
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
62
|
+
report("error", "capabilities-not-mapping", "", "能力要求文档顶层必须是映射(对象)");
|
|
63
|
+
return { ok: false, capabilities: null, diagnostics };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const key of Object.keys(value)) {
|
|
67
|
+
if (!ROOT_FIELDS.has(key)) {
|
|
68
|
+
report(
|
|
69
|
+
"error",
|
|
70
|
+
"unknown-field",
|
|
71
|
+
key,
|
|
72
|
+
`未知字段 "${key}"。能力要求只接受 ${[...ROOT_FIELDS].join(" / ")};` +
|
|
73
|
+
"模型路由、插件版本、凭据指针没有证据源,写进来只会变成永不核对的假绿。",
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (value.kind !== CAPABILITIES_KIND) {
|
|
79
|
+
report(
|
|
80
|
+
"error",
|
|
81
|
+
"invalid-kind",
|
|
82
|
+
"kind",
|
|
83
|
+
`kind 必须是 "${CAPABILITIES_KIND}",实际是 ${describe(value.kind)}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rosterId = value.roster;
|
|
88
|
+
if (typeof rosterId !== "string" || rosterId === "") {
|
|
89
|
+
report("error", "missing-field", "roster", "缺少 roster(这份要求属于哪份档案)");
|
|
90
|
+
} else if (!ID_PATTERN.test(rosterId)) {
|
|
91
|
+
report("error", "invalid-id", "roster", `roster 必须是 kebab-case 的 2~64 字符:${rosterId}`);
|
|
92
|
+
} else if (expectedId !== null && rosterId !== expectedId) {
|
|
93
|
+
report(
|
|
94
|
+
"error",
|
|
95
|
+
"roster-id-mismatch",
|
|
96
|
+
"roster",
|
|
97
|
+
`roster 字段(${rosterId})必须与所在目录名(${expectedId})一致`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let onMissing = DEFAULT_ON_MISSING;
|
|
102
|
+
if (value.onMissing !== undefined && value.onMissing !== null) {
|
|
103
|
+
if (!ON_MISSING.includes(value.onMissing)) {
|
|
104
|
+
report(
|
|
105
|
+
"error",
|
|
106
|
+
"invalid-on-missing",
|
|
107
|
+
"onMissing",
|
|
108
|
+
`onMissing 只能是 ${ON_MISSING.join(" / ")},实际是 ${describe(value.onMissing)}`,
|
|
109
|
+
);
|
|
110
|
+
} else {
|
|
111
|
+
onMissing = value.onMissing;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let asOf = null;
|
|
116
|
+
if (value.asOf !== undefined && value.asOf !== null) {
|
|
117
|
+
if (typeof value.asOf !== "string" || !AS_OF_PATTERN.test(value.asOf)) {
|
|
118
|
+
report("error", "invalid-as-of", "asOf", `asOf 必须是 YYYY-MM-DD 日期:${describe(value.asOf)}`);
|
|
119
|
+
} else {
|
|
120
|
+
asOf = value.asOf;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const notes = optionalText(value.notes, "notes", LIMITS.notesChars, report);
|
|
125
|
+
const requires = validateRequires(value.requires, report);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: diagnostics.every((item) => item.severity !== "error"),
|
|
129
|
+
capabilities: {
|
|
130
|
+
kind: CAPABILITIES_KIND,
|
|
131
|
+
roster: typeof rosterId === "string" ? rosterId : null,
|
|
132
|
+
onMissing,
|
|
133
|
+
asOf,
|
|
134
|
+
requires,
|
|
135
|
+
notes,
|
|
136
|
+
},
|
|
137
|
+
diagnostics,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateRequires(raw, report) {
|
|
142
|
+
const empty = { skills: [], toolPolicies: [] };
|
|
143
|
+
if (raw === undefined || raw === null) return empty;
|
|
144
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
145
|
+
report("error", "invalid-requires", "requires", "requires 必须是映射(对象)");
|
|
146
|
+
return empty;
|
|
147
|
+
}
|
|
148
|
+
for (const key of Object.keys(raw)) {
|
|
149
|
+
if (!REQUIRES_FIELDS.has(key)) {
|
|
150
|
+
report(
|
|
151
|
+
"error",
|
|
152
|
+
"unknown-field",
|
|
153
|
+
`requires.${key}`,
|
|
154
|
+
`未知的能力类别 "requires.${key}"。目前只核对 skills 与 toolPolicies;` +
|
|
155
|
+
"其余类别没有证据源,不接收。",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
skills: requirementList(raw.skills, "requires.skills", SKILL_PATTERN, report),
|
|
161
|
+
toolPolicies: requirementList(raw.toolPolicies, "requires.toolPolicies", ID_PATTERN, report),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 读取一组能力要求。每一项可以写成裸字符串,也可以写成 `{name, reason}`:
|
|
167
|
+
* 裸字符串够用于 CI,带 `reason` 是给人看的——"为什么需要它"往往比"需要它"更重要。
|
|
168
|
+
*/
|
|
169
|
+
function requirementList(raw, path, pattern, report) {
|
|
170
|
+
if (raw === undefined || raw === null) return [];
|
|
171
|
+
if (!Array.isArray(raw)) {
|
|
172
|
+
report("error", "invalid-requirement-list", path, `${path} 必须是数组`);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
if (raw.length > LIMITS.requirements) {
|
|
176
|
+
report("error", "too-many-requirements", path, `${path} 最多 ${LIMITS.requirements} 项`);
|
|
177
|
+
return [];
|
|
178
|
+
}
|
|
179
|
+
const seen = new Map();
|
|
180
|
+
const out = [];
|
|
181
|
+
raw.forEach((item, index) => {
|
|
182
|
+
const at = `${path}[${index}]`;
|
|
183
|
+
let name;
|
|
184
|
+
let reason = null;
|
|
185
|
+
if (typeof item === "string") {
|
|
186
|
+
name = item;
|
|
187
|
+
} else if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
188
|
+
for (const key of Object.keys(item)) {
|
|
189
|
+
if (key !== "name" && key !== "reason") {
|
|
190
|
+
report("error", "unknown-field", `${at}.${key}`, `未知字段 "${key}",只接受 name / reason`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
name = item.name;
|
|
194
|
+
if (item.reason !== undefined && item.reason !== null) {
|
|
195
|
+
reason = optionalText(item.reason, `${at}.reason`, LIMITS.reasonChars, report);
|
|
196
|
+
}
|
|
197
|
+
} else {
|
|
198
|
+
report("error", "invalid-requirement", at, `${at} 必须是字符串或 {name, reason} 对象`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (typeof name !== "string" || name === "") {
|
|
202
|
+
report("error", "missing-field", `${at}.name`, `${at} 缺少 name`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
if (name.length > LIMITS.nameChars) {
|
|
206
|
+
report("error", "name-too-long", `${at}.name`, `${at}.name 最长 ${LIMITS.nameChars} 字符`);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!pattern.test(name)) {
|
|
210
|
+
report("error", "invalid-requirement-name", `${at}.name`, `${at}.name 不合格式:${name}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (seen.has(name)) {
|
|
214
|
+
report(
|
|
215
|
+
"error",
|
|
216
|
+
"duplicate-requirement",
|
|
217
|
+
`${at}.name`,
|
|
218
|
+
`${name} 重复声明(已出现在 ${seen.get(name)});重复不会让要求更强,只会让人以为写了两条。`,
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
seen.set(name, at);
|
|
223
|
+
out.push({ name, reason });
|
|
224
|
+
});
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function optionalText(value, path, limit, report) {
|
|
229
|
+
if (value === undefined || value === null) return null;
|
|
230
|
+
if (typeof value !== "string") {
|
|
231
|
+
report("error", "invalid-type", path, `${path} 必须是字符串`);
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
const text = value.trim();
|
|
235
|
+
if (text === "") return null;
|
|
236
|
+
if (text.length > limit) {
|
|
237
|
+
report("error", "too-long", path, `${path} 最长 ${limit} 字符,实际 ${text.length}`);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (/[\r\n]/.test(text)) {
|
|
241
|
+
report("error", "multiline-text", path, `${path} 必须是单行文本`);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
return text;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function describe(value) {
|
|
248
|
+
if (value === null) return "null";
|
|
249
|
+
if (value === undefined) return "(缺失)";
|
|
250
|
+
if (typeof value === "string") return `"${value}"`;
|
|
251
|
+
if (Array.isArray(value)) return "数组";
|
|
252
|
+
return typeof value === "object" ? "对象" : String(value);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 把能力要求摊平成一串待核对项。
|
|
257
|
+
*
|
|
258
|
+
* @param {object | null} capabilities
|
|
259
|
+
* @returns {{ kind: "skill" | "toolPolicy", name: string, reason: string | null }[]}
|
|
260
|
+
*/
|
|
261
|
+
export function capabilityRequirements(capabilities) {
|
|
262
|
+
if (capabilities === null || capabilities === undefined) return [];
|
|
263
|
+
const requires = capabilities.requires ?? {};
|
|
264
|
+
return [
|
|
265
|
+
...(requires.skills ?? []).map((item) => ({ kind: "skill", name: item.name, reason: item.reason })),
|
|
266
|
+
...(requires.toolPolicies ?? []).map((item) => ({
|
|
267
|
+
kind: "toolPolicy",
|
|
268
|
+
name: item.name,
|
|
269
|
+
reason: item.reason,
|
|
270
|
+
})),
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 核对能力要求。
|
|
276
|
+
*
|
|
277
|
+
* 三种结果,**互不冒充**:
|
|
278
|
+
* - `present`:证据源说它在;
|
|
279
|
+
* - `missing`:证据源说它不在(这是唯一会让 `refuse` 生效的状态);
|
|
280
|
+
* - `unknown`:没有证据源(技能服务没挂、`knownToolPolicies` 没配),既不算过也不算错。
|
|
281
|
+
*
|
|
282
|
+
* @param {object[]} rosters 已通过校验的档案
|
|
283
|
+
* @param {Record<string, object>} capabilitiesById 档案 id → 能力要求对象
|
|
284
|
+
* @param {{ knownSkills?: string[] | null, knownToolPolicies?: string[] | null }} [evidence]
|
|
285
|
+
* @returns {{ entries: object[], summary: object }}
|
|
286
|
+
*/
|
|
287
|
+
export function checkCapabilities(rosters, capabilitiesById, evidence = {}) {
|
|
288
|
+
const knownSkills =
|
|
289
|
+
Array.isArray(evidence.knownSkills) && evidence.knownSkills.length > 0
|
|
290
|
+
? new Set(evidence.knownSkills.filter((name) => typeof name === "string" && name !== ""))
|
|
291
|
+
: null;
|
|
292
|
+
const knownPolicies =
|
|
293
|
+
Array.isArray(evidence.knownToolPolicies) && evidence.knownToolPolicies.length > 0
|
|
294
|
+
? new Set(evidence.knownToolPolicies.filter((id) => typeof id === "string" && id !== ""))
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
const entries = [];
|
|
298
|
+
const summary = { declared: 0, present: 0, missing: 0, unknown: 0, blocked: 0, degraded: 0 };
|
|
299
|
+
|
|
300
|
+
for (const roster of rosters) {
|
|
301
|
+
const capabilities = capabilitiesById?.[roster.id] ?? null;
|
|
302
|
+
const requirements = capabilityRequirements(capabilities);
|
|
303
|
+
if (requirements.length === 0 && capabilities === null) continue;
|
|
304
|
+
|
|
305
|
+
const missing = [];
|
|
306
|
+
const unknown = [];
|
|
307
|
+
const present = [];
|
|
308
|
+
for (const requirement of requirements) {
|
|
309
|
+
const evidenceSet = requirement.kind === "skill" ? knownSkills : knownPolicies;
|
|
310
|
+
if (evidenceSet === null) {
|
|
311
|
+
unknown.push(requirement);
|
|
312
|
+
} else if (evidenceSet.has(requirement.name)) {
|
|
313
|
+
present.push(requirement);
|
|
314
|
+
} else {
|
|
315
|
+
missing.push(requirement);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const onMissing = capabilities?.onMissing ?? DEFAULT_ON_MISSING;
|
|
320
|
+
// 只有"确实缺"才触发策略;核对不到不许当成缺,否则配不上证据源的机器会全红。
|
|
321
|
+
const blocked = onMissing === "refuse" && missing.length > 0;
|
|
322
|
+
summary.declared += requirements.length;
|
|
323
|
+
summary.present += present.length;
|
|
324
|
+
summary.missing += missing.length;
|
|
325
|
+
summary.unknown += unknown.length;
|
|
326
|
+
if (blocked) summary.blocked += 1;
|
|
327
|
+
else if (onMissing === "degraded" && missing.length > 0) summary.degraded += 1;
|
|
328
|
+
|
|
329
|
+
entries.push({
|
|
330
|
+
rosterId: roster.id,
|
|
331
|
+
version: roster.version,
|
|
332
|
+
onMissing,
|
|
333
|
+
asOf: capabilities?.asOf ?? null,
|
|
334
|
+
requires: requirements.length,
|
|
335
|
+
present,
|
|
336
|
+
missing,
|
|
337
|
+
unknown,
|
|
338
|
+
blocked,
|
|
339
|
+
status: blocked ? "blocked" : missing.length > 0 ? "degraded" : unknown.length > 0 ? "unchecked" : "ok",
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return { entries, summary };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** 能力类别的人话名与"该装到哪"——拒绝时直接告诉人怎么修。 */
|
|
347
|
+
export const CAPABILITY_HINTS = {
|
|
348
|
+
skill: {
|
|
349
|
+
label: "技能",
|
|
350
|
+
install:
|
|
351
|
+
"把技能目录放进仓库的 .dsh/skills/ 或全局 ~/.dsh/skills/(由 dsh-skill-filesystem 监听,拷进去即生效)",
|
|
352
|
+
},
|
|
353
|
+
toolPolicy: {
|
|
354
|
+
label: "工具策略",
|
|
355
|
+
install: "在本插件配置的 knownToolPolicies 里声明该 id,并在 profile 的 toolPolicies 中定义它",
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 面板层的能力诊断:把"能同步判定的部分"变成诊断条目。
|
|
361
|
+
*
|
|
362
|
+
* 为什么只判工具策略:面板快照是同步的,而技能目录只能通过宿主的技能服务异步列出来。
|
|
363
|
+
* 与其在面板里编一个"看起来通过"的结论,不如把技能要求标成"面板不核对"——
|
|
364
|
+
* 这与 `checkCapabilities` 的 `unknown` 是同一个态度:没有证据源就说没有,不冒充通过。
|
|
365
|
+
*
|
|
366
|
+
* @param {Record<string, object>} capabilitiesById 档案 id → 能力要求
|
|
367
|
+
* @param {Record<string, string>} capabilityFiles 档案 id → 能力文件相对路径
|
|
368
|
+
* @param {string[] | null} knownToolPolicies 部署已声明的策略 id
|
|
369
|
+
* @returns {object[]} 与 `scanRosterLibrary` 同形状的诊断
|
|
370
|
+
*/
|
|
371
|
+
export function capabilityDiagnostics(capabilitiesById, capabilityFiles, knownToolPolicies) {
|
|
372
|
+
const ids = Object.keys(capabilitiesById ?? {}).sort();
|
|
373
|
+
if (ids.length === 0) return [];
|
|
374
|
+
|
|
375
|
+
const policies = Array.isArray(knownToolPolicies) ? knownToolPolicies : null;
|
|
376
|
+
const diagnostics = [];
|
|
377
|
+
const skipped = [];
|
|
378
|
+
|
|
379
|
+
for (const id of ids) {
|
|
380
|
+
const capabilities = capabilitiesById[id];
|
|
381
|
+
const file = capabilityFiles?.[id] ?? `${id}/capabilities.yaml`;
|
|
382
|
+
const base = { path: "", file };
|
|
383
|
+
|
|
384
|
+
const declaredPolicies = (capabilities.requires?.toolPolicies ?? []).map((item) => item.name);
|
|
385
|
+
const declaredSkills = (capabilities.requires?.skills ?? []).map((item) => item.name);
|
|
386
|
+
|
|
387
|
+
if (declaredPolicies.length > 0) {
|
|
388
|
+
if (policies === null) {
|
|
389
|
+
// 证据源缺席:逐份档案刷"无法核对"只会变成噪音,汇总成一条。
|
|
390
|
+
skipped.push(id);
|
|
391
|
+
} else {
|
|
392
|
+
const missing = declaredPolicies.filter((name) => !policies.includes(name));
|
|
393
|
+
if (missing.length > 0) {
|
|
394
|
+
const refuse = (capabilities.onMissing ?? DEFAULT_ON_MISSING) === "refuse";
|
|
395
|
+
diagnostics.push({
|
|
396
|
+
...base,
|
|
397
|
+
severity: refuse ? "error" : "warn",
|
|
398
|
+
code: "capability-policy-missing",
|
|
399
|
+
reason: refuse
|
|
400
|
+
? `档案 "${id}" 要求工具策略 ${missing.join("、")},但部署的 knownToolPolicies 里没有;` +
|
|
401
|
+
"onMissing: refuse,建队会被拒绝。"
|
|
402
|
+
: `档案 "${id}" 要求工具策略 ${missing.join("、")},但部署的 knownToolPolicies 里没有;` +
|
|
403
|
+
"onMissing: degraded,建队照常,但每次核对都会记为 degraded。",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (declaredSkills.length > 0) {
|
|
410
|
+
diagnostics.push({
|
|
411
|
+
...base,
|
|
412
|
+
severity: "info",
|
|
413
|
+
code: "capability-skills-unverified",
|
|
414
|
+
reason:
|
|
415
|
+
`档案 "${id}" 声明了技能要求 ${declaredSkills.join("、")};` +
|
|
416
|
+
"面板不列技能目录,因此这里不判定——用 roster_verify 核对(技能服务在场时才会给出缺/不缺)。",
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (skipped.length > 0) {
|
|
422
|
+
diagnostics.push({
|
|
423
|
+
severity: "info",
|
|
424
|
+
code: "capability-cross-check-skipped",
|
|
425
|
+
path: "",
|
|
426
|
+
file: null,
|
|
427
|
+
reason:
|
|
428
|
+
`未配置 knownToolPolicies:${skipped.join("、")} 的 toolPolicies 要求只做格式校验,不与部署交叉核对。` +
|
|
429
|
+
"把部署里存在的策略 id 列进该配置即可启用核对。",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return diagnostics;
|
|
434
|
+
}
|
package/lib/diff.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 档案 ↔ 档案的差异:换班时第一个要回答的问题就是"新一代改了什么"。
|
|
3
|
+
*
|
|
4
|
+
* 已有的核对都是"档案 ↔ 运行态"(`roster_verify`),它回答不了这个问题:
|
|
5
|
+
* 同一个团队换成新一代档案后,运行态与**新**档案一致,旧档案与新档案的差别却没人记得。
|
|
6
|
+
* 所以这里只做纯函数比较,不读文件、不碰团队状态——载入与解析由调用方负责。
|
|
7
|
+
*
|
|
8
|
+
* 三条判读纪律:
|
|
9
|
+
* 1. **成员按 `name` 对齐**,`aliases` 只作提示不自动合并——否则"改名"会被静默吞成"没变"。
|
|
10
|
+
* 2. **比较的是有效值**(`defaults` 已合进成员),并标注这个值是成员自己写的还是继承来的;
|
|
11
|
+
* 档案里把 `model` 从成员搬到 `defaults`、值不变,不算变更。
|
|
12
|
+
* 3. **人设不进差异正文**,只给长度与内容哈希——8000 字人设并排贴在终端里没人读得下去,
|
|
13
|
+
* 而"长度变了 / 哈希变了"足够回答"改没改"。
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-agent-roster/diff
|
|
16
|
+
*/
|
|
17
|
+
import { sha256Hex, shortSha } from "./digest.js";
|
|
18
|
+
|
|
19
|
+
/** 参与逐一比较的成员字段(顺序即输出顺序)。 */
|
|
20
|
+
const MEMBER_FIELDS = ["role", "provider", "model", "reasoningEffort", "toolPolicy", "toolScope", "maxTokens"];
|
|
21
|
+
|
|
22
|
+
/** 档案顶层参与比较的字段。 */
|
|
23
|
+
const ROSTER_FIELDS = ["version", "title", "description", "license"];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 比较两份已校验档案。
|
|
27
|
+
*
|
|
28
|
+
* @param {object} from 旧档案
|
|
29
|
+
* @param {object} to 新档案
|
|
30
|
+
* @returns {{ rosters: object[], members: object, skills: object[], counts: object }}
|
|
31
|
+
*/
|
|
32
|
+
export function diffRosters(from, to) {
|
|
33
|
+
const members = diffMembers(from, to);
|
|
34
|
+
return {
|
|
35
|
+
rosters: diffRosterFields(from, to),
|
|
36
|
+
members,
|
|
37
|
+
skills: diffSkills(from, to),
|
|
38
|
+
counts: { added: members.added.length, removed: members.removed.length, changed: members.changed.length },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 档案顶层字段。
|
|
44
|
+
*
|
|
45
|
+
* @param {object} from
|
|
46
|
+
* @param {object} to
|
|
47
|
+
* @returns {{ field: string, from: string, to: string }[]}
|
|
48
|
+
*/
|
|
49
|
+
function diffRosterFields(from, to) {
|
|
50
|
+
const changes = [];
|
|
51
|
+
for (const field of ROSTER_FIELDS) {
|
|
52
|
+
const before = textOf(from?.[field]);
|
|
53
|
+
const after = textOf(to?.[field]);
|
|
54
|
+
if (before !== after) changes.push({ field, from: before, to: after });
|
|
55
|
+
}
|
|
56
|
+
for (const field of ["provider", "model", "reasoningEffort"]) {
|
|
57
|
+
const before = textOf(from?.defaults?.[field]);
|
|
58
|
+
const after = textOf(to?.defaults?.[field]);
|
|
59
|
+
if (before !== after) changes.push({ field: `defaults.${field}`, from: before, to: after });
|
|
60
|
+
}
|
|
61
|
+
return changes;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 成员层面的增删改。
|
|
66
|
+
*
|
|
67
|
+
* @param {object} from
|
|
68
|
+
* @param {object} to
|
|
69
|
+
* @returns {{ added: string[], removed: string[], changed: object[] }}
|
|
70
|
+
*/
|
|
71
|
+
function diffMembers(from, to) {
|
|
72
|
+
const before = membersByName(from);
|
|
73
|
+
const after = membersByName(to);
|
|
74
|
+
const added = [...after.keys()].filter((name) => !before.has(name));
|
|
75
|
+
const removed = [...before.keys()].filter((name) => !after.has(name));
|
|
76
|
+
const changed = [];
|
|
77
|
+
for (const [name, newMember] of after) {
|
|
78
|
+
const oldMember = before.get(name);
|
|
79
|
+
if (oldMember === undefined) continue;
|
|
80
|
+
const fields = [];
|
|
81
|
+
for (const field of MEMBER_FIELDS) {
|
|
82
|
+
const left = textOf(oldMember[field]);
|
|
83
|
+
const right = textOf(newMember[field]);
|
|
84
|
+
if (left === right) continue;
|
|
85
|
+
fields.push({
|
|
86
|
+
field,
|
|
87
|
+
from: left,
|
|
88
|
+
to: right,
|
|
89
|
+
origin: originOf(oldMember, newMember, field),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const persona = diffPersona(oldMember.systemPrompt, newMember.systemPrompt);
|
|
93
|
+
if (persona !== null) fields.push(persona);
|
|
94
|
+
const aliases = diffList(oldMember.aliases, newMember.aliases, "aliases");
|
|
95
|
+
if (aliases !== null) fields.push(aliases);
|
|
96
|
+
if (fields.length > 0) changed.push({ name, fields });
|
|
97
|
+
}
|
|
98
|
+
return { added, removed, changed };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 人设只比长度与哈希。两段文字一模一样时连这一行都不出现。
|
|
103
|
+
*
|
|
104
|
+
* @param {unknown} before
|
|
105
|
+
* @param {unknown} after
|
|
106
|
+
* @returns {{ field: string, from: string, to: string } | null}
|
|
107
|
+
*/
|
|
108
|
+
function diffPersona(before, after) {
|
|
109
|
+
const left = typeof before === "string" ? before : "";
|
|
110
|
+
const right = typeof after === "string" ? after : "";
|
|
111
|
+
if (left === right) return null;
|
|
112
|
+
return { field: "systemPrompt", from: fingerprintOf(left), to: fingerprintOf(right) };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 列表字段(aliases / skills)按集合比较,顺序变化不算变更。
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} before
|
|
119
|
+
* @param {unknown} after
|
|
120
|
+
* @param {string} field
|
|
121
|
+
* @returns {{ field: string, from: string, to: string } | null}
|
|
122
|
+
*/
|
|
123
|
+
function diffList(before, after, field) {
|
|
124
|
+
const left = listOf(before);
|
|
125
|
+
const right = listOf(after);
|
|
126
|
+
if (left.join("\u0000") === right.join("\u0000")) return null;
|
|
127
|
+
return { field, from: left.join(", "), to: right.join(", ") };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 技能按成员逐条比增减——换班时最常问的是"新加的成员要装什么技能"。
|
|
132
|
+
*
|
|
133
|
+
* @param {object} from
|
|
134
|
+
* @param {object} to
|
|
135
|
+
* @returns {{ member: string, added: string[], removed: string[] }[]}
|
|
136
|
+
*/
|
|
137
|
+
function diffSkills(from, to) {
|
|
138
|
+
const before = membersByName(from);
|
|
139
|
+
const after = membersByName(to);
|
|
140
|
+
const changes = [];
|
|
141
|
+
for (const [name, newMember] of after) {
|
|
142
|
+
const oldMember = before.get(name);
|
|
143
|
+
if (oldMember === undefined) continue;
|
|
144
|
+
const left = new Set(listOf(oldMember.skills));
|
|
145
|
+
const right = new Set(listOf(newMember.skills));
|
|
146
|
+
const added = [...right].filter((item) => !left.has(item));
|
|
147
|
+
const removed = [...left].filter((item) => !right.has(item));
|
|
148
|
+
if (added.length > 0 || removed.length > 0) changes.push({ member: name, added, removed });
|
|
149
|
+
}
|
|
150
|
+
return changes;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 成员名 → 成员,跳过没有名字的记录(校验层已拦,这里只防崩)。
|
|
155
|
+
*
|
|
156
|
+
* @param {object} roster
|
|
157
|
+
* @returns {Map<string, object>}
|
|
158
|
+
*/
|
|
159
|
+
function membersByName(roster) {
|
|
160
|
+
const map = new Map();
|
|
161
|
+
for (const member of Array.isArray(roster?.members) ? roster.members : []) {
|
|
162
|
+
if (member !== null && typeof member === "object" && typeof member.name === "string" && member.name !== "") {
|
|
163
|
+
map.set(member.name, member);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return map;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* 这个字段的差别来自哪里:成员自己改的,还是从 `defaults` 继承来的。
|
|
171
|
+
*
|
|
172
|
+
* @param {object} before
|
|
173
|
+
* @param {object} after
|
|
174
|
+
* @param {string} field
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function originOf(before, after, field) {
|
|
178
|
+
const left = before.inherited?.includes(field) ? "defaults" : "member";
|
|
179
|
+
const right = after.inherited?.includes(field) ? "defaults" : "member";
|
|
180
|
+
if (left === right) return right;
|
|
181
|
+
return `${left} → ${right}`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 人读指纹:`1234 字符 #ab12cd34`。
|
|
186
|
+
*
|
|
187
|
+
* @param {string} text
|
|
188
|
+
* @returns {string}
|
|
189
|
+
*/
|
|
190
|
+
function fingerprintOf(text) {
|
|
191
|
+
if (text === "") return "(空)";
|
|
192
|
+
return `${text.length} 字符 #${shortSha(sha256Hex(text))}`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 标量转文本:缺省显示成 `(未声明)`,避免 `undefined` 混进差异表。
|
|
197
|
+
*
|
|
198
|
+
* @param {unknown} value
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function textOf(value) {
|
|
202
|
+
if (value === undefined || value === null) return "(未声明)";
|
|
203
|
+
if (typeof value === "string") return value === "" ? "(空)" : value;
|
|
204
|
+
return String(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* 字符串数组规范化:去空、排序、去重。
|
|
209
|
+
*
|
|
210
|
+
* @param {unknown} value
|
|
211
|
+
* @returns {string[]}
|
|
212
|
+
*/
|
|
213
|
+
function listOf(value) {
|
|
214
|
+
if (!Array.isArray(value)) return [];
|
|
215
|
+
return [...new Set(value.filter((item) => typeof item === "string" && item !== ""))].sort();
|
|
216
|
+
}
|