@stofone/dsh-agent-roster 0.5.1 → 0.6.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 +24 -0
- package/README.md +31 -2
- package/README.zh.md +26 -2
- package/client.js +459 -61
- package/index.js +41 -1
- package/lib/apply.js +186 -0
- package/lib/captain.js +259 -0
- package/lib/render.js +25 -0
- package/lib/service.js +50 -0
- package/lib/tools.js +20 -1
- package/locale/en.json +48 -3
- package/locale/zh.json +48 -3
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { readFileSync } from "node:fs";
|
|
|
17
17
|
import z from "@deepseek-ai/schemastery";
|
|
18
18
|
|
|
19
19
|
import { configView } from "./lib/config-view.js";
|
|
20
|
+
import { applyCaptain, listSessions } from "./lib/apply.js";
|
|
20
21
|
import { collectLibrary } from "./lib/library.js";
|
|
21
22
|
import { parseDocumentText } from "./lib/parse.js";
|
|
22
23
|
import { validateRoster } from "./lib/validate.js";
|
|
@@ -111,7 +112,9 @@ export function apply(ctx, config) {
|
|
|
111
112
|
const query = new URL(req.url ?? "/", "http://localhost").searchParams;
|
|
112
113
|
// 远程源是“附属信息”:拉不到也不能让本地档案库的响应失败,所以失败只体现在 remote.diagnostics 里。
|
|
113
114
|
const remote = await loadRemotePanel(settings, { refresh: query.get("refresh") === "1" });
|
|
114
|
-
|
|
115
|
+
// 会话列表也是“附属信息”:没有 sessionController(或它报错)时面板仍要能用。
|
|
116
|
+
const sessions = await listSessions(ctx.get?.("sessionController"));
|
|
117
|
+
sendJson(res, 200, { ...snapshot, remote, sessions });
|
|
115
118
|
},
|
|
116
119
|
"library route",
|
|
117
120
|
);
|
|
@@ -238,6 +241,43 @@ export function apply(ctx, config) {
|
|
|
238
241
|
"install route",
|
|
239
242
|
);
|
|
240
243
|
|
|
244
|
+
register(
|
|
245
|
+
"/plugins/dsh-agent-roster/apply",
|
|
246
|
+
async (req, res) => {
|
|
247
|
+
if (guard(req, res)) return;
|
|
248
|
+
if (!requireMethod(req, res, "POST")) return;
|
|
249
|
+
const body = await readJsonBody(req);
|
|
250
|
+
if (!body.ok) {
|
|
251
|
+
sendError(res, body.status, body.code);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const input = body.value !== null && typeof body.value === "object" ? body.value : {};
|
|
255
|
+
const rosterId = typeof input.rosterId === "string" ? input.rosterId : "";
|
|
256
|
+
if (rosterId === "") {
|
|
257
|
+
sendError(res, 400, "missing-roster");
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
// 只允许对“档案库里真实存在且校验通过”的档案动手:输入不构造路径,也不走缓存。
|
|
261
|
+
const library = collectLibrary(ctx, settings, { name: pkg.name, version: pkg.version });
|
|
262
|
+
const rosters = library.workspaces.flatMap((workspace) => workspace.rosters);
|
|
263
|
+
const roster = rosters.find((item) => item.id === rosterId);
|
|
264
|
+
if (roster === undefined) {
|
|
265
|
+
sendJson(res, 404, { ok: false, error: { code: "roster-not-found" }, known: rosters.map((item) => item.id) });
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const result = await applyCaptain({
|
|
269
|
+
sessionController: ctx.get?.("sessionController"),
|
|
270
|
+
roster,
|
|
271
|
+
mode: input.mode === "new" ? "new" : "existing",
|
|
272
|
+
sessionId: typeof input.sessionId === "string" ? input.sessionId : "",
|
|
273
|
+
cwd: typeof input.cwd === "string" ? input.cwd : "",
|
|
274
|
+
});
|
|
275
|
+
const status = result.ok ? 200 : result.code === "session-controller-unavailable" ? 503 : 400;
|
|
276
|
+
sendJson(res, status, result);
|
|
277
|
+
},
|
|
278
|
+
"apply route",
|
|
279
|
+
);
|
|
280
|
+
|
|
241
281
|
register(
|
|
242
282
|
"/plugins/dsh-agent-roster/rosters",
|
|
243
283
|
async (req, res) => {
|
package/lib/apply.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* “应用队长档案”:把一份档案搬到某个会话里,让那个会话按档案去建团队。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是这个形态:队长**不是**能被 `agent_teams_add_member` 创建出来的对象,
|
|
5
|
+
* 它就是"创建团队的那个会话"。所以要"应用队长档案",能做的只有三件事:
|
|
6
|
+
*
|
|
7
|
+
* 1. 选一个会话(已有的,或新建一个);
|
|
8
|
+
* 2. 把档案声明的模型选到那个会话上(`sessionController.selectModel`);
|
|
9
|
+
* 3. 往那个会话里排一条提示,让它按档案建团队(`sessionController.prompt`)。
|
|
10
|
+
*
|
|
11
|
+
* 第 2 步是**真的写进运行时**(模型选择是会话级持久投影),第 3 步只是让会话开始干活;
|
|
12
|
+
* 人格(persona)仍然来自部署的 agent preset,档案里只能声明,改不了。
|
|
13
|
+
*
|
|
14
|
+
* 这一层不 import 任何宿主包:`sessionController` 由调用方传进来,方便测试。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-agent-roster/apply
|
|
17
|
+
*/
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
|
|
20
|
+
import { MEMBER_FIELDS } from "./format.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 会话头上的模型投影:`next` 是下一次请求要用的,`lastUsed` 是上一次真正用过的。
|
|
24
|
+
*
|
|
25
|
+
* @param {object | null | undefined} summary `SessionSummary`
|
|
26
|
+
* @returns {{ provider: string, model: string, reasoningEffort: string } | null}
|
|
27
|
+
*/
|
|
28
|
+
export function pickSessionModel(summary) {
|
|
29
|
+
const values = summary?.projections?.values;
|
|
30
|
+
const projection = values?.modelSelection;
|
|
31
|
+
const selection = projection?.next ?? projection?.lastUsed ?? null;
|
|
32
|
+
if (selection === null || typeof selection !== "object") return null;
|
|
33
|
+
return {
|
|
34
|
+
provider: typeof selection.provider === "string" ? selection.provider : "",
|
|
35
|
+
model: typeof selection.model === "string" ? selection.model : "",
|
|
36
|
+
reasoningEffort: typeof selection.reasoningEffort === "string" ? selection.reasoningEffort : "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 列可选会话(剔除子代理会话,它们不是队长候选)。
|
|
42
|
+
*
|
|
43
|
+
* @param {object | null | undefined} sessionController 宿主 `sessionController` 服务
|
|
44
|
+
* @returns {Promise<{ ok: boolean, code?: string, sessions: object[] }>}
|
|
45
|
+
*/
|
|
46
|
+
export async function listSessions(sessionController) {
|
|
47
|
+
if (sessionController === null || typeof sessionController !== "object" || typeof sessionController.list !== "function") {
|
|
48
|
+
return { ok: false, code: "session-controller-unavailable", sessions: [] };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const page = await sessionController.list({});
|
|
52
|
+
const items = Array.isArray(page?.items) ? page.items : [];
|
|
53
|
+
const sessions = items
|
|
54
|
+
.filter((item) => item?.origin !== "subagent")
|
|
55
|
+
.map((item) => ({
|
|
56
|
+
sessionId: String(item.sessionId ?? ""),
|
|
57
|
+
cwd: typeof item.cwd === "string" ? item.cwd : "",
|
|
58
|
+
updatedAt: typeof item.updatedAt === "number" ? item.updatedAt : 0,
|
|
59
|
+
running: item.running === true,
|
|
60
|
+
blank: item.blank === true,
|
|
61
|
+
model: pickSessionModel(item),
|
|
62
|
+
}))
|
|
63
|
+
.filter((item) => item.sessionId !== "");
|
|
64
|
+
return { ok: true, sessions };
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return { ok: false, code: "session-list-failed", reason: String(error?.message ?? error), sessions: [] };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 一段能给会话照着执行的提示词。
|
|
72
|
+
*
|
|
73
|
+
* 只写"要它做什么"和"从哪拿参数",不复制成员人设原文:人设可能很长,
|
|
74
|
+
* 而 `roster_show` 已经会把它和建员参数一起给出,重复一遍只会浪费上下文。
|
|
75
|
+
*
|
|
76
|
+
* @param {{ roster: object, teamName?: string }} input
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function buildCaptainPrompt(input) {
|
|
80
|
+
const roster = input.roster;
|
|
81
|
+
const id = String(roster?.id ?? "");
|
|
82
|
+
const title = typeof roster?.title === "string" && roster.title !== "" ? roster.title : id;
|
|
83
|
+
const teamName = typeof input.teamName === "string" && input.teamName !== "" ? input.teamName : id;
|
|
84
|
+
const members = Array.isArray(roster?.members) ? roster.members : [];
|
|
85
|
+
const table = members
|
|
86
|
+
.map((member) => {
|
|
87
|
+
const bits = [`name: ${member.name}`, `role: ${member.role}`];
|
|
88
|
+
for (const field of MEMBER_FIELDS) {
|
|
89
|
+
if (field === "name" || field === "role" || field === "systemPrompt") continue;
|
|
90
|
+
const value = member[field];
|
|
91
|
+
if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) continue;
|
|
92
|
+
bits.push(`${field}: ${Array.isArray(value) ? value.join(",") : value}`);
|
|
93
|
+
}
|
|
94
|
+
return `- ${bits.join(" | ")}`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n");
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
`请按档案 \`${id}\`(${title})创建一个多 Agent 团队,队长就是你(当前会话)。`,
|
|
100
|
+
"",
|
|
101
|
+
"步骤:",
|
|
102
|
+
`1. 调用 \`roster_show\`,参数 id: "${id}"。它会给出每个成员的“建员参数”(含人设原文),请以它为准。`,
|
|
103
|
+
`2. 调用 \`agent_teams_create\`,参数 name: "${teamName}",description 用档案的 description 或你自己概括,approval: "automatic"。`,
|
|
104
|
+
`3. 对每个成员调用 \`agent_teams_add_member\`:name / role / executionPrompt 用人设原文,provider / model / reasoningEffort / toolPolicy 按建员参数填;provenance 写 "roster:${id}"。`,
|
|
105
|
+
`4. 成员都加上后调用 \`roster_verify\`(teamId 用上一步返回的团队 id),确认每个成员的字段是否如实进了运行时。`,
|
|
106
|
+
"5. 把核对结果摘要给我(哪几个成员、哪些字段 not-carried),不要粘贴成员人设原文。",
|
|
107
|
+
"",
|
|
108
|
+
"本次要建的成员:",
|
|
109
|
+
table === "" ? "- (档案里没有成员)" : table,
|
|
110
|
+
"",
|
|
111
|
+
"注意:你的模型/人格不来自这份档案——档案的 captain 段只是声明;要改队长本身,得改部署配置里的 agent-default-model 与 preset persona。档案里的成员模型/provider 若与事实不符,以档案为准去 add_member,不要臆造。",
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 把档案“应用”到一个会话:新建或复用会话 → 选模型 → 排提示。
|
|
117
|
+
*
|
|
118
|
+
* @param {object} input
|
|
119
|
+
* @param {object | null | undefined} input.sessionController 宿主 `sessionController` 服务
|
|
120
|
+
* @param {object} input.roster 已校验的档案(含 captain 段)
|
|
121
|
+
* @param {"new" | "existing"} input.mode
|
|
122
|
+
* @param {string} [input.sessionId] `existing` 时必填
|
|
123
|
+
* @param {string} [input.cwd] `new` 时可指定会话工作目录,缺省用当前工作区
|
|
124
|
+
* @param {string} [input.requestId] 便于测试固定 requestId
|
|
125
|
+
* @returns {Promise<object>}
|
|
126
|
+
*/
|
|
127
|
+
export async function applyCaptain(input) {
|
|
128
|
+
const sessionController = input.sessionController;
|
|
129
|
+
if (
|
|
130
|
+
sessionController === null ||
|
|
131
|
+
typeof sessionController !== "object" ||
|
|
132
|
+
typeof sessionController.create !== "function" ||
|
|
133
|
+
typeof sessionController.prompt !== "function"
|
|
134
|
+
) {
|
|
135
|
+
return { ok: false, code: "session-controller-unavailable" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const roster = input.roster;
|
|
139
|
+
const captain = roster?.captain ?? {};
|
|
140
|
+
const mode = input.mode === "new" ? "new" : "existing";
|
|
141
|
+
|
|
142
|
+
let sessionId = typeof input.sessionId === "string" ? input.sessionId : "";
|
|
143
|
+
if (mode === "new") {
|
|
144
|
+
try {
|
|
145
|
+
const created = await sessionController.create(input.cwd ? { cwd: input.cwd } : {});
|
|
146
|
+
sessionId = String(created?.sessionId ?? "");
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return { ok: false, code: "session-create-failed", reason: String(error?.message ?? error) };
|
|
149
|
+
}
|
|
150
|
+
if (sessionId === "") return { ok: false, code: "session-create-failed" };
|
|
151
|
+
} else if (sessionId === "") {
|
|
152
|
+
return { ok: false, code: "missing-session" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let model = null;
|
|
156
|
+
const provider = typeof captain.provider === "string" ? captain.provider : "";
|
|
157
|
+
const modelId = typeof captain.model === "string" ? captain.model : "";
|
|
158
|
+
if (provider !== "" && modelId !== "") {
|
|
159
|
+
if (typeof sessionController.selectModel !== "function") {
|
|
160
|
+
return { ok: false, code: "model-selection-unavailable", sessionId };
|
|
161
|
+
}
|
|
162
|
+
const selection = { sessionId, provider, model: modelId };
|
|
163
|
+
const effort = typeof captain.reasoningEffort === "string" ? captain.reasoningEffort : "";
|
|
164
|
+
if (effort !== "" && effort !== "default") selection.reasoningEffort = effort;
|
|
165
|
+
try {
|
|
166
|
+
const applied = await sessionController.selectModel(selection);
|
|
167
|
+
model = applied?.selected ?? { provider, model: modelId, reasoningEffort: selection.reasoningEffort ?? "" };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
// 选不上就**不要**继续排提示:那样会建出一个模型与档案不符的团队,比直接失败更难查。
|
|
170
|
+
return { ok: false, code: "model-not-applied", sessionId, reason: String(error?.message ?? error) };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await sessionController.prompt({
|
|
176
|
+
requestId: typeof input.requestId === "string" && input.requestId !== "" ? input.requestId : randomUUID(),
|
|
177
|
+
sessionId,
|
|
178
|
+
mode: "queue",
|
|
179
|
+
content: [{ type: "text", text: buildCaptainPrompt({ roster, teamName: input.teamName }) }],
|
|
180
|
+
});
|
|
181
|
+
} catch (error) {
|
|
182
|
+
return { ok: false, code: "prompt-not-queued", sessionId, reason: String(error?.message ?? error) };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { ok: true, sessionId, model, queued: true, mode };
|
|
186
|
+
}
|
package/lib/captain.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 队长(captain)侧:把部署里真实写着的队长配置读出来,和档案里的声明逐字段核对。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要单独一层:队长**不是一个成员**。agent-teams 的 state 里只有
|
|
5
|
+
* `captainSessionId` 与 `captainInbox`,没有 captain 成员行,也没有任何工具参数能改写
|
|
6
|
+
* 队长的路由——队长就是"创建团队的那个会话",它的模型来自部署配置
|
|
7
|
+
* (profile 的 `agent-default-model`),它的人格来自 agent preset 的 persona。
|
|
8
|
+
*
|
|
9
|
+
* 所以档案里的 `captain` 段只能声明,落地要动部署配置。这一层负责两件事:
|
|
10
|
+
* 1. 从 profile 的 `cordis.patch.yml` 里读出**实际值**(provider / model / persona 前缀);
|
|
11
|
+
* 2. 把实际值和声明逐字段比对,给出 `match / differ / undeclared / unknown` 四态。
|
|
12
|
+
*
|
|
13
|
+
* 解析只用行扫描、不引 YAML 解析器:这些 patch 文件里含 `!!js` 标签,
|
|
14
|
+
* 通用 YAML 解析会直接抛错;而我们要的字段全是顶格的行结构。
|
|
15
|
+
*
|
|
16
|
+
* @module dsh-agent-roster/captain
|
|
17
|
+
*/
|
|
18
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
19
|
+
import nodePath from "node:path";
|
|
20
|
+
|
|
21
|
+
import { displayDshHome, resolveDshHome } from "./home.js";
|
|
22
|
+
|
|
23
|
+
/** 部署配置里承载队长模型的插件行 id。 */
|
|
24
|
+
export const MODEL_ROW_ID = "agent-default-model";
|
|
25
|
+
|
|
26
|
+
/** agent preset 行的 id 前缀。 */
|
|
27
|
+
export const PRESET_ID_PREFIX = "preset-";
|
|
28
|
+
|
|
29
|
+
const PATCH_FILE_NAME = "cordis.patch.yml";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 去掉包裹用的引号(只处理配对的首尾引号,不碰内部字符)。
|
|
33
|
+
*
|
|
34
|
+
* @param {string} text
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function unquote(text) {
|
|
38
|
+
const value = text.trim();
|
|
39
|
+
if (value.length >= 2) {
|
|
40
|
+
const first = value[0];
|
|
41
|
+
const last = value[value.length - 1];
|
|
42
|
+
if ((first === "'" && last === "'") || (first === '"' && last === '"')) {
|
|
43
|
+
return value.slice(1, -1);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 解析 patch 文本,返回**所有** preset 的 persona 前缀(上面的单行扫描已按 preset 分组,
|
|
51
|
+
* 这里补一份完整列表,便于核对任意 preset)。
|
|
52
|
+
*
|
|
53
|
+
* @param {string} text
|
|
54
|
+
* @param {string} profile
|
|
55
|
+
* @returns {{ profile: string, provider: string, model: string, reasoningEffort: string, personas: object[] }}
|
|
56
|
+
*/
|
|
57
|
+
export function parseDeployedProfile(text, profile) {
|
|
58
|
+
const lines = String(text ?? "").split(/\r?\n/);
|
|
59
|
+
const result = { profile, provider: "", model: "", reasoningEffort: "", personas: [] };
|
|
60
|
+
|
|
61
|
+
let row = null;
|
|
62
|
+
let inPersona = false;
|
|
63
|
+
let blockIndent = -1;
|
|
64
|
+
let blockLines = [];
|
|
65
|
+
|
|
66
|
+
const flushBlock = () => {
|
|
67
|
+
if (blockLines.length > 0 && row?.kind === "preset") {
|
|
68
|
+
row.personaPrefixes.push(blockLines.join("\n").trim());
|
|
69
|
+
}
|
|
70
|
+
blockLines = [];
|
|
71
|
+
blockIndent = -1;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const closePreset = () => {
|
|
75
|
+
if (row?.kind === "preset") {
|
|
76
|
+
for (const prefix of row.personaPrefixes) {
|
|
77
|
+
result.personas.push({ preset: row.id, prefix });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const line of lines) {
|
|
83
|
+
if (blockIndent >= 0) {
|
|
84
|
+
const indent = line.length - line.trimStart().length;
|
|
85
|
+
if (line.trim() !== "" && indent >= blockIndent) {
|
|
86
|
+
blockLines.push(line.trim());
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
flushBlock();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const top = /^-\s+id:\s*(\S+)\s*$/.exec(line);
|
|
93
|
+
if (top !== null) {
|
|
94
|
+
closePreset();
|
|
95
|
+
const id = top[1];
|
|
96
|
+
if (id === MODEL_ROW_ID) row = { kind: "model" };
|
|
97
|
+
else if (id.startsWith(PRESET_ID_PREFIX)) row = { kind: "preset", id, personaPrefixes: [] };
|
|
98
|
+
else row = { kind: "other" };
|
|
99
|
+
inPersona = false;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (row === null) continue;
|
|
103
|
+
|
|
104
|
+
if (row.kind === "model") {
|
|
105
|
+
const field = /^\s+(provider|model|reasoningEffort):\s*(.+?)\s*$/.exec(line);
|
|
106
|
+
if (field !== null) {
|
|
107
|
+
const value = unquote(field[2]);
|
|
108
|
+
if (field[1] === "provider") result.provider = value;
|
|
109
|
+
else if (field[1] === "model") result.model = value;
|
|
110
|
+
else result.reasoningEffort = value;
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (row.kind === "preset") {
|
|
116
|
+
const sub = /^\s+-\s+id:\s*(\S+)\s*$/.exec(line);
|
|
117
|
+
if (sub !== null) {
|
|
118
|
+
inPersona = sub[1] === "persona";
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (!inPersona) continue;
|
|
122
|
+
const prefix = /^(\s+)prefix:\s*(.*)$/.exec(line);
|
|
123
|
+
if (prefix === null) continue;
|
|
124
|
+
const value = prefix[2].trim();
|
|
125
|
+
if (value === "|" || value === ">" || value === "|-" || value === ">-") {
|
|
126
|
+
blockIndent = prefix[1].length + 1;
|
|
127
|
+
blockLines = [];
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (value !== "") row.personaPrefixes.push(unquote(value));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
flushBlock();
|
|
134
|
+
closePreset();
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 读本机所有 profile 的队长配置。
|
|
141
|
+
*
|
|
142
|
+
* @param {{ dshHome?: string, env?: Record<string, string | undefined> }} [options]
|
|
143
|
+
* @returns {{ home: string, displayed: string, profiles: object[], diagnostics: object[] }}
|
|
144
|
+
*/
|
|
145
|
+
export function readDeployedCaptains(options = {}) {
|
|
146
|
+
const env = options.env ?? process.env;
|
|
147
|
+
const home = resolveDshHome(options.dshHome, env);
|
|
148
|
+
const profilesDir = nodePath.join(home, "profiles");
|
|
149
|
+
const diagnostics = [];
|
|
150
|
+
const profiles = [];
|
|
151
|
+
|
|
152
|
+
if (!existsSync(profilesDir)) {
|
|
153
|
+
return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let names = [];
|
|
157
|
+
try {
|
|
158
|
+
names = readdirSync(profilesDir, { withFileTypes: true })
|
|
159
|
+
.filter((entry) => entry.isDirectory())
|
|
160
|
+
.map((entry) => entry.name)
|
|
161
|
+
.sort();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
diagnostics.push({
|
|
164
|
+
severity: "warn",
|
|
165
|
+
code: "profiles-unreadable",
|
|
166
|
+
reason: String(error?.message ?? error),
|
|
167
|
+
});
|
|
168
|
+
return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const name of names) {
|
|
172
|
+
const file = nodePath.join(profilesDir, name, PATCH_FILE_NAME);
|
|
173
|
+
if (!existsSync(file)) continue;
|
|
174
|
+
try {
|
|
175
|
+
profiles.push(parseDeployedProfile(readFileSync(file, "utf8"), name));
|
|
176
|
+
} catch (error) {
|
|
177
|
+
diagnostics.push({
|
|
178
|
+
severity: "warn",
|
|
179
|
+
code: "profile-unreadable",
|
|
180
|
+
params: { profile: name },
|
|
181
|
+
reason: String(error?.message ?? error),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { home, displayed: displayDshHome(home, env), profiles, diagnostics };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 逐个字段比对"档案声明"与"部署实际值"。
|
|
191
|
+
*
|
|
192
|
+
* 四态含义:
|
|
193
|
+
* - `match`:声明了且部署一致;
|
|
194
|
+
* - `differ`:声明了但部署不一致(要改部署配置,改档案没用);
|
|
195
|
+
* - `undeclared`:档案没声明,运行时用部署默认(不是错误,只是没有约束);
|
|
196
|
+
* - `unknown`:读不到部署配置,无法判断(也不能假装通过)。
|
|
197
|
+
*
|
|
198
|
+
* @param {object | null | undefined} captain 档案的 captain 段
|
|
199
|
+
* @param {{ profiles?: object[], displayed?: string }} deployed `readDeployedCaptains` 的结果
|
|
200
|
+
* @returns {{ checks: object[], ok: boolean, target: object | null, profiles: object[] }}
|
|
201
|
+
*/
|
|
202
|
+
export function verifyCaptain(captain, deployed = {}) {
|
|
203
|
+
const declared = captain !== null && typeof captain === "object" ? captain : {};
|
|
204
|
+
const profiles = Array.isArray(deployed.profiles) ? deployed.profiles : [];
|
|
205
|
+
// 多 profile 时无法确定"这个会话到底跑哪一个",所以只给出每个字段的"任一匹配"结论,
|
|
206
|
+
// 并把候选 profile 名单一起返回,让界面说清楚"这是哪个 profile 的值"。
|
|
207
|
+
const target = profiles.length === 1 ? profiles[0] : null;
|
|
208
|
+
const compare = (field, declaredValue, actualValues) => {
|
|
209
|
+
const values = actualValues.filter((value) => typeof value === "string" && value !== "");
|
|
210
|
+
if (typeof declaredValue !== "string" || declaredValue === "") {
|
|
211
|
+
const fallback = values.length > 0 ? values[0] : "";
|
|
212
|
+
return {
|
|
213
|
+
field,
|
|
214
|
+
status: fallback === "" ? "undeclared" : "undeclared",
|
|
215
|
+
declared: "",
|
|
216
|
+
actual: fallback,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (values.length === 0) {
|
|
220
|
+
return { field, status: "unknown", declared: declaredValue, actual: "" };
|
|
221
|
+
}
|
|
222
|
+
const hit = values.includes(declaredValue);
|
|
223
|
+
return {
|
|
224
|
+
field,
|
|
225
|
+
status: hit ? "match" : "differ",
|
|
226
|
+
declared: declaredValue,
|
|
227
|
+
actual: values.join(", "),
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const checks = [
|
|
232
|
+
compare("provider", declared.provider, profiles.map((profile) => profile.provider)),
|
|
233
|
+
compare("model", declared.model, profiles.map((profile) => profile.model)),
|
|
234
|
+
compare(
|
|
235
|
+
"reasoningEffort",
|
|
236
|
+
declared.reasoningEffort === "default" ? "" : declared.reasoningEffort,
|
|
237
|
+
profiles.map((profile) => profile.reasoningEffort),
|
|
238
|
+
),
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const personaPrefix = typeof declared.personaPrefix === "string" ? declared.personaPrefix.trim() : "";
|
|
242
|
+
const prefixes = profiles.flatMap((profile) => profile.personas.map((persona) => persona.prefix));
|
|
243
|
+
if (personaPrefix === "") {
|
|
244
|
+
checks.push({ field: "personaPrefix", status: prefixes.length > 0 ? "undeclared" : "unknown", declared: "", actual: prefixes[0] ?? "" });
|
|
245
|
+
} else if (prefixes.length === 0) {
|
|
246
|
+
checks.push({ field: "personaPrefix", status: "unknown", declared: personaPrefix, actual: "" });
|
|
247
|
+
} else {
|
|
248
|
+
const hit = prefixes.some((prefix) => prefix.trim() === personaPrefix);
|
|
249
|
+
checks.push({
|
|
250
|
+
field: "personaPrefix",
|
|
251
|
+
status: hit ? "match" : "differ",
|
|
252
|
+
declared: personaPrefix,
|
|
253
|
+
actual: prefixes[0],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const ok = checks.every((check) => check.status === "match" || check.status === "undeclared");
|
|
258
|
+
return { checks, ok, target, profiles };
|
|
259
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -309,6 +309,31 @@ export function renderCaptainSection(captain) {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
/** 状态标签,供 UI 与报告共用。 */
|
|
312
|
+
/**
|
|
313
|
+
* 队长核对结果:逐字段给「一致 / 不一致 / 未声明 / 无法核对」。
|
|
314
|
+
*
|
|
315
|
+
* 四态是刻意的:读不到部署配置时不能写成「通过」,也不能写成「不一致」。
|
|
316
|
+
*
|
|
317
|
+
* @param {{ checks?: object[] }} verified
|
|
318
|
+
* @param {{ displayed?: string }} [options]
|
|
319
|
+
* @returns {string}
|
|
320
|
+
*/
|
|
321
|
+
export function renderCaptainVerification(verified, options = {}) {
|
|
322
|
+
const lines = [];
|
|
323
|
+
const where = options.displayed === undefined || options.displayed === "" ? "" : `(部署:${options.displayed})`;
|
|
324
|
+
lines.push(`队长档案核对${where}`);
|
|
325
|
+
for (const check of verified.checks ?? []) {
|
|
326
|
+
if (check.status === "match") lines.push(` ✓ 一致 ${check.field} ${check.declared}`);
|
|
327
|
+
else if (check.status === "differ") {
|
|
328
|
+
lines.push(` ✗ 不一致 ${check.field} 声明 ${check.declared || "(空)"} / 部署 ${check.actual || "(空)"}`);
|
|
329
|
+
} else if (check.status === "undeclared") {
|
|
330
|
+
lines.push(` · 未声明 ${check.field} 未声明,运行时用部署默认${check.actual === "" ? "" : "(" + check.actual + ")"}`);
|
|
331
|
+
} else lines.push(` ? 无法核对 ${check.field} 读不到部署配置`);
|
|
332
|
+
}
|
|
333
|
+
lines.push(" 说明:队长=创建团队的那个会话,没有成员行;模型来自部署 agent-default-model,人格来自 preset 的 persona。");
|
|
334
|
+
return lines.join("\n");
|
|
335
|
+
}
|
|
336
|
+
|
|
312
337
|
export function statusLabel(status) {
|
|
313
338
|
return BINDING_STATUS[status] ?? status;
|
|
314
339
|
}
|
package/lib/service.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import nodePath from "node:path";
|
|
14
14
|
|
|
15
15
|
import { globalRosterRoot } from "./home.js";
|
|
16
|
+
import { readDeployedCaptains, verifyCaptain } from "./captain.js";
|
|
16
17
|
import { collectLibrary, listWorkspaces } from "./library.js";
|
|
17
18
|
import { CACHE_DIR_NAME, fetchCollection, loadRegistryIndex } from "./remote.js";
|
|
18
19
|
import { rosterDocumentText } from "./serialize.js";
|
|
@@ -74,6 +75,7 @@ export function buildPanelSnapshot(ctx, config, meta, options = {}) {
|
|
|
74
75
|
|
|
75
76
|
const hints = buildHints({ library, roots, rosters, teams, config });
|
|
76
77
|
const deployments = countDeployments(rosters, teams.teams);
|
|
78
|
+
const captain = buildCaptainSection(rosters, options);
|
|
77
79
|
|
|
78
80
|
return {
|
|
79
81
|
plugin: meta.name,
|
|
@@ -89,6 +91,7 @@ export function buildPanelSnapshot(ctx, config, meta, options = {}) {
|
|
|
89
91
|
workspaces: library.workspaces,
|
|
90
92
|
teams,
|
|
91
93
|
deployments,
|
|
94
|
+
captain,
|
|
92
95
|
hints,
|
|
93
96
|
summary: {
|
|
94
97
|
rosters: rosters.length,
|
|
@@ -241,6 +244,53 @@ export function countDeployments(rosters, teams) {
|
|
|
241
244
|
return result;
|
|
242
245
|
}
|
|
243
246
|
|
|
247
|
+
/**
|
|
248
|
+
* 队长段:部署里的实际值 + 每份档案的核对结果。
|
|
249
|
+
*
|
|
250
|
+
* 部署值读不到不算错(核对待核字段会给 `unknown`),但一定如实标出来——不假装通过。
|
|
251
|
+
*
|
|
252
|
+
* @param {object[]} rosters
|
|
253
|
+
* @param {{ readDeployed?: () => object }} [options] 测试注入
|
|
254
|
+
* @returns {{ deployed: object, reports: object[] }}
|
|
255
|
+
*/
|
|
256
|
+
export function buildCaptainSection(rosters, options = {}) {
|
|
257
|
+
const readDeployed = options.readDeployed ?? (() => readDeployedCaptains());
|
|
258
|
+
let deployed;
|
|
259
|
+
try {
|
|
260
|
+
deployed = readDeployed();
|
|
261
|
+
} catch (error) {
|
|
262
|
+
deployed = {
|
|
263
|
+
home: "",
|
|
264
|
+
displayed: "",
|
|
265
|
+
profiles: [],
|
|
266
|
+
diagnostics: [{ severity: "warn", code: "captain-unreadable", reason: String(error?.message ?? error) }],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const reports = rosters
|
|
271
|
+
.filter((roster) => roster.captain !== undefined && roster.captain !== null)
|
|
272
|
+
.map((roster) => {
|
|
273
|
+
const verified = verifyCaptain(roster.captain, deployed);
|
|
274
|
+
return { rosterId: roster.id, rootDir: roster.rootDir ?? "", checks: verified.checks, ok: verified.ok };
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
deployed: {
|
|
279
|
+
home: deployed.home ?? "",
|
|
280
|
+
displayed: deployed.displayed ?? "",
|
|
281
|
+
profiles: (deployed.profiles ?? []).map((profile) => ({
|
|
282
|
+
profile: profile.profile,
|
|
283
|
+
provider: profile.provider,
|
|
284
|
+
model: profile.model,
|
|
285
|
+
reasoningEffort: profile.reasoningEffort,
|
|
286
|
+
personas: (profile.personas ?? []).length,
|
|
287
|
+
})),
|
|
288
|
+
diagnostics: deployed.diagnostics ?? [],
|
|
289
|
+
},
|
|
290
|
+
reports,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
244
294
|
/**
|
|
245
295
|
* 配置与环境体检:只产出 code + params,客户端负责措辞。
|
|
246
296
|
*
|
package/lib/tools.js
CHANGED
|
@@ -14,7 +14,8 @@ import { LIMITS } from "./format.js";
|
|
|
14
14
|
import { resolveDirSpec } from "./home.js";
|
|
15
15
|
import { lossless } from "./json.js";
|
|
16
16
|
import { scanRosterLibrary, findRoster } from "./registry.js";
|
|
17
|
-
import { renderBridgePlan, renderRosterDetail, renderRosterList, renderVerifyReport } from "./render.js";
|
|
17
|
+
import { renderBridgePlan, renderCaptainVerification, renderRosterDetail, renderRosterList, renderVerifyReport } from "./render.js";
|
|
18
|
+
import { readDeployedCaptains, verifyCaptain } from "./captain.js";
|
|
18
19
|
import { readTeamStates, resolveStateDir } from "./teams-state.js";
|
|
19
20
|
import { filterChecks, verifyBindings } from "./verify.js";
|
|
20
21
|
|
|
@@ -349,6 +350,11 @@ export function registerRosterTools(ctx, config, meta) {
|
|
|
349
350
|
// 技能是声明层:只有宿主真的能列出技能目录时才核对,否则如实记成"无法核对"。
|
|
350
351
|
const knownSkills = await readKnownSkills(ctx, workspace);
|
|
351
352
|
let report = verifyBindings(rosters, state.teams, { knownSkills });
|
|
353
|
+
// 队长不是成员行:只能拿实际的部署配置去比档案里的声明,读不到就记成“无法核对”。
|
|
354
|
+
const deployedCaptains = readDeployedCaptains();
|
|
355
|
+
const captainReports = rosters
|
|
356
|
+
.filter((roster) => roster.captain !== undefined && roster.captain !== null)
|
|
357
|
+
.map((roster) => ({ roster, verified: verifyCaptain(roster.captain, deployedCaptains) }));
|
|
352
358
|
if (Array.isArray(args.members) && args.members.length > 0) {
|
|
353
359
|
report = filterChecks(report, args.members);
|
|
354
360
|
}
|
|
@@ -364,8 +370,16 @@ export function registerRosterTools(ctx, config, meta) {
|
|
|
364
370
|
missing: report.summary.missing,
|
|
365
371
|
unmanaged: report.summary.unmanaged,
|
|
366
372
|
skills: `${report.summary.skillsMissing}/${report.summary.skillsDeclared}`,
|
|
373
|
+
captain: captainReports.length,
|
|
367
374
|
}),
|
|
368
375
|
renderVerifyReport(report, { rosterIds: refRosterIds }),
|
|
376
|
+
...(captainReports.length === 0
|
|
377
|
+
? []
|
|
378
|
+
: [
|
|
379
|
+
captainReports
|
|
380
|
+
.map(({ verified }) => renderCaptainVerification(verified, { displayed: deployedCaptains.displayed }))
|
|
381
|
+
.join("\n\n"),
|
|
382
|
+
]),
|
|
369
383
|
].join("\n\n");
|
|
370
384
|
return lossless({
|
|
371
385
|
ok,
|
|
@@ -382,6 +396,11 @@ export function registerRosterTools(ctx, config, meta) {
|
|
|
382
396
|
fields: check.binding === null ? [] : check.binding.fields,
|
|
383
397
|
})),
|
|
384
398
|
unmanaged: report.unmanaged,
|
|
399
|
+
captain: captainReports.map(({ roster, verified }) => ({
|
|
400
|
+
rosterId: roster.id,
|
|
401
|
+
ok: verified.ok,
|
|
402
|
+
checks: verified.checks,
|
|
403
|
+
})),
|
|
385
404
|
skills: report.skills,
|
|
386
405
|
diagnostics: [...scan.diagnostics, ...state.diagnostics],
|
|
387
406
|
text,
|