dsh-armory 1.9.2 → 1.9.4
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/README.en.md +43 -11
- package/README.md +55 -12
- package/lib/client.js +1020 -615
- package/lib/expert-market.js +175 -6
- package/lib/index.js +46 -8
- package/lib/mcp-config.js +4 -0
- package/lib/tool-ledger.js +111 -14
- package/lib/whats-new.js +46 -0
- package/lib/workspace-runtime.js +3 -1
- package/package.json +18 -4
package/lib/expert-market.js
CHANGED
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
// - 技能落盘目标与 SkillHub 技能同一发现范围 ~/.agents/skills/,目录名带 wbexpert__ 前缀
|
|
28
28
|
// 防与其它来源撞名;本地缓存复制整树,CDN 兜底只写 SKILL.md 并如实标注。
|
|
29
29
|
// - patch 写入用 yaml 文档 API(与 mcp-config.js 同款 !!js 标量约定),保留其余条目与注释。
|
|
30
|
-
import { cp, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
30
|
+
import { cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
31
31
|
import { homedir } from "node:os";
|
|
32
|
-
import { basename, join
|
|
32
|
+
import { basename, join } from "node:path";
|
|
33
33
|
import { parseDocument, Scalar, YAMLMap, YAMLSeq, isMap, isSeq } from "yaml";
|
|
34
34
|
|
|
35
35
|
const EXPERT_ORIGIN = "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/expert-marketplace";
|
|
@@ -60,6 +60,14 @@ function skillsRoot() {
|
|
|
60
60
|
function workbuddyExpertCache() {
|
|
61
61
|
return join(homedir(), ".workbuddy", "plugins", "cache", "experts");
|
|
62
62
|
}
|
|
63
|
+
// 团队专家:主理人 + N 个成员,每人一份人设。成员人设不塞进 preset(会白白撑大每轮
|
|
64
|
+
// 系统提示),而是落盘成独立 md,由主理人调度时按需 read 进 subagent 的 prompt。
|
|
65
|
+
function teamMemberDir(plugin) {
|
|
66
|
+
return join(cacheDir(), "teams", safeSegment(plugin));
|
|
67
|
+
}
|
|
68
|
+
function teamMemberPersonaPath(plugin, memberId) {
|
|
69
|
+
return join(teamMemberDir(plugin), `${safeSegment(memberId)}.md`);
|
|
70
|
+
}
|
|
63
71
|
function safeSegment(value) {
|
|
64
72
|
// 目录名防穿越:只留安全字符,与 wb-market.js 的 skillTargetName 同一纪律。
|
|
65
73
|
const safe = String(value).toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48);
|
|
@@ -143,15 +151,33 @@ async function loadManifest() {
|
|
|
143
151
|
function bi(value) {
|
|
144
152
|
return value && typeof value === "object" ? String(value.zh || value.en || "") : String(value || "");
|
|
145
153
|
}
|
|
154
|
+
function biMember(value) {
|
|
155
|
+
return value && typeof value === "object" ? String(value.zh || value.en || "") : String(value || "");
|
|
156
|
+
}
|
|
157
|
+
/** 团队编制里的一行:成员 ID / 花名 / 职业 / 角色 / 人设文件名。 */
|
|
158
|
+
function projectMember(member) {
|
|
159
|
+
const id = String(member?.id || "").trim();
|
|
160
|
+
if (!id) return null;
|
|
161
|
+
return {
|
|
162
|
+
id,
|
|
163
|
+
name: biMember(member.displayName || member.name),
|
|
164
|
+
profession: biMember(member.profession),
|
|
165
|
+
role: String(member.role || "member"),
|
|
166
|
+
promptFile: String(member.promptFile || ""),
|
|
167
|
+
avatar: String(member.avatar || ""),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
146
170
|
function projectExpert(entry, categoryById) {
|
|
147
171
|
const plugin = String(entry.plugin || "").trim();
|
|
148
172
|
if (!plugin) return null;
|
|
149
173
|
const category = categoryById.get(entry.categoryId);
|
|
174
|
+
const members = Array.isArray(entry.members) ? entry.members.map(projectMember).filter(Boolean) : [];
|
|
150
175
|
return {
|
|
151
176
|
id: String(entry.id || plugin),
|
|
152
177
|
plugin,
|
|
153
178
|
agentName: String(entry.agentName || ""),
|
|
154
179
|
expertType: entry.expertType === "team" ? "team" : "agent",
|
|
180
|
+
members,
|
|
155
181
|
name: bi(entry.displayName) || plugin,
|
|
156
182
|
profession: bi(entry.profession),
|
|
157
183
|
description: bi(entry.description),
|
|
@@ -298,6 +324,120 @@ export async function installExpertSkills(plugin, skillNames, { onLog } = {}) {
|
|
|
298
324
|
}
|
|
299
325
|
return { installed, notes };
|
|
300
326
|
}
|
|
327
|
+
// ── 团队专家:成员人设落盘(召唤时执行,送还时整体清除)─────────────────
|
|
328
|
+
async function localMemberPersona(plugin, memberId) {
|
|
329
|
+
for (const version of await localExpertVersions(plugin)) {
|
|
330
|
+
const candidate = join(version.dir, "agents", `${memberId}.md`);
|
|
331
|
+
try {
|
|
332
|
+
const text = await readFile(candidate, "utf8");
|
|
333
|
+
if (text.trim()) return text;
|
|
334
|
+
} catch { /* 该版本没有这个成员 */ }
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
async function readMemberPersona(plugin, member) {
|
|
339
|
+
const local = await localMemberPersona(plugin, member.id);
|
|
340
|
+
if (local) return { text: local, source: "workbuddy-cache" };
|
|
341
|
+
const promptFile = String(member.promptFile || `/plugins/${plugin}/agents/${member.id}.md`).trim();
|
|
342
|
+
const text = await fetchText(EXPERT_ORIGIN + promptFile);
|
|
343
|
+
if (!text.trim()) throw new Error("人设文件为空");
|
|
344
|
+
return { text, source: "cdn" };
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* 把团队成员的独立人设写成本机 md(不进 preset:5 份人设塞进系统提示会按轮计费)。
|
|
348
|
+
* 主理人调度时先 read 出来再拼进 subagent 的 prompt,语义等同 WorkBuddy 的独立协作方。
|
|
349
|
+
* @returns {Promise<{installed: Array, notes: string[], dir: string}>}
|
|
350
|
+
*/
|
|
351
|
+
export async function installTeamMemberPersonas(plugin, members, { onLog } = {}) {
|
|
352
|
+
const installed = [];
|
|
353
|
+
const notes = [];
|
|
354
|
+
const dir = teamMemberDir(plugin);
|
|
355
|
+
for (const member of Array.isArray(members) ? members : []) {
|
|
356
|
+
// 主理人人设本身就是召唤预设的 persona,不需要再落一份。
|
|
357
|
+
if (member.role === "lead") continue;
|
|
358
|
+
try {
|
|
359
|
+
const { text, source } = await readMemberPersona(plugin, member);
|
|
360
|
+
await mkdir(dir, { recursive: true });
|
|
361
|
+
const path = teamMemberPersonaPath(plugin, member.id);
|
|
362
|
+
await writeFile(path, text);
|
|
363
|
+
installed.push({ id: member.id, name: member.name, profession: member.profession, path, source });
|
|
364
|
+
onLog?.(`团队成员 ${member.name || member.id}:人设已落盘(${source})`);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
notes.push(`团队成员 ${member.name || member.id} 人设获取失败:${error.message}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return { installed, notes, dir };
|
|
370
|
+
}
|
|
371
|
+
export async function uninstallTeamMemberPersonas(plugin) {
|
|
372
|
+
const dir = teamMemberDir(plugin);
|
|
373
|
+
try {
|
|
374
|
+
await rm(dir, { recursive: true, force: true });
|
|
375
|
+
return dir;
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* DSH 运行时桥接段:WorkBuddy 团队人设里的 TeamCreate / Agent(name, subagent_type)
|
|
382
|
+
* 在 DSH 里不存在,这里给出等价映射。追加在主理人人设末尾,只在召唤时拼一次。
|
|
383
|
+
* @param {object} detail wbExpertDetail 的返回值
|
|
384
|
+
* @param {Map<string,string>} personaFiles 成员 ID → 已落盘的人设路径(未落盘则不传)
|
|
385
|
+
*/
|
|
386
|
+
// 桥接段的唯一识别标记:readExpertPresets 靠它在召唤预设的人设正文里判断
|
|
387
|
+
// 「这条团队预设带没带桥接段」。旧版本召唤写入的团队预设没有这段——那种预设下的
|
|
388
|
+
// 会话主理人会去调不存在的 TeamCreate / Agent,成员永远开不出来(用户实测症状)。
|
|
389
|
+
export const TEAM_BRIDGE_MARKER = "## DSH 运行时适配(团队协作桥接)";
|
|
390
|
+
/** 团队人设正文是否已带桥接段(用于现存召唤预设行的 stale 体检)。 */
|
|
391
|
+
export function hasTeamBridge(personaText) {
|
|
392
|
+
return typeof personaText === "string" && personaText.includes(TEAM_BRIDGE_MARKER);
|
|
393
|
+
}
|
|
394
|
+
export function buildTeamBridge(detail, personaFiles) {
|
|
395
|
+
const members = Array.isArray(detail?.members) ? detail.members : [];
|
|
396
|
+
const others = members.filter((member) => member.role !== "lead");
|
|
397
|
+
if (!others.length) return "";
|
|
398
|
+
const rows = others.map((member) => {
|
|
399
|
+
const file = personaFiles?.get(member.id);
|
|
400
|
+
return `| \`${member.id}\` | ${member.name || "-"} | ${member.profession || "-"} | ${file ? `\`${file}\`` : "(人设未落盘,按职责执行)"} |`;
|
|
401
|
+
});
|
|
402
|
+
return [
|
|
403
|
+
"",
|
|
404
|
+
"---",
|
|
405
|
+
"",
|
|
406
|
+
"## DSH 运行时适配(团队协作桥接)",
|
|
407
|
+
"",
|
|
408
|
+
"本会话运行在 DSH 上,**没有 WorkBuddy 的 `TeamCreate` 与 `Agent` 工具**。团队编制已在召唤时固定,因此上文里「建立团队」「Agent 工具 name/subagent_type」两类指令按下面的等价方式执行;其余铁律(不得代写成员产出、成员之间不直连、一切经你中转)保持不变。",
|
|
409
|
+
"",
|
|
410
|
+
"1. **建团队 → 无需调用**:本会话即本团队,成员固定如下表,不需要创建、也不需要命名。",
|
|
411
|
+
"2. **调度成员 → `subagent` 工具**:",
|
|
412
|
+
" - `description`:`<成员ID>:<阶段产出名>`,例如 `software-architect:系统设计与任务分解`。",
|
|
413
|
+
" - `prompt`:先用 `read` 读出该成员的人设文件(下表最后一列),把**人设正文原文**放在 prompt 最前面,接一行 `---`,再往下写本次任务、上游产出、期望交付物与验收标准。",
|
|
414
|
+
" - 顺序 SOP(PRD → 架构 → 实现 → QA)**必须**传 `run_in_background: false`,当场等它返回再往下走;只有互不依赖的并行任务才留默认后台模式。",
|
|
415
|
+
"3. **成员产出**:子代理的最终回复就是该成员的专业产出,原文转交下一阶段,不要改写、不要替它编。",
|
|
416
|
+
"4. **续聊追问**:需要同一成员接着干活时,用 `send_message` 继续它的会话 id,不要重新 spawn 一个同名成员。",
|
|
417
|
+
"5. **上下文继承**:`subagent` 是干净上下文(更接近 WorkBuddy 的独立协作方);只有确实需要它继承你当前会话上下文时才用 `subagent_fork`。",
|
|
418
|
+
"",
|
|
419
|
+
"### 团队编制",
|
|
420
|
+
"",
|
|
421
|
+
"| 成员 ID | 花名 | 职责 | 人设文件 |",
|
|
422
|
+
"| --- | --- | --- | --- |",
|
|
423
|
+
...rows,
|
|
424
|
+
"",
|
|
425
|
+
].join("\n");
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* 把「详情」变成真正可用的会话人设:单人专家原样返回,团队专家先落成员人设、
|
|
429
|
+
* 再在末尾追加 DSH 桥接段。召唤预设与会话级挂载走同一条路径,保证两个入口一致。
|
|
430
|
+
* @returns {Promise<{personaText: string, memberResult: object|null}>}
|
|
431
|
+
*/
|
|
432
|
+
export async function prepareExpertPersona(detail, { onLog, withMembers = true } = {}) {
|
|
433
|
+
if (detail?.expertType !== "team") return { personaText: detail?.persona || "", memberResult: null };
|
|
434
|
+
let memberResult = { installed: [], notes: [], dir: teamMemberDir(detail.plugin) };
|
|
435
|
+
if (withMembers) {
|
|
436
|
+
memberResult = await installTeamMemberPersonas(detail.plugin, detail.members, { onLog });
|
|
437
|
+
}
|
|
438
|
+
const files = new Map((memberResult.installed || []).map((row) => [row.id, row.path]));
|
|
439
|
+
return { personaText: `${detail.persona}${buildTeamBridge(detail, files)}`, memberResult };
|
|
440
|
+
}
|
|
301
441
|
export async function uninstallExpertSkills(plugin) {
|
|
302
442
|
const root = skillsRoot();
|
|
303
443
|
let names = [];
|
|
@@ -390,7 +530,20 @@ export function readExpertPresets(content) {
|
|
|
390
530
|
const config = entry.get("config", true);
|
|
391
531
|
const presetId = String(mapValue(config, "id") || "");
|
|
392
532
|
if (!presetId.startsWith(EXPERT_PRESET_PREFIX)) continue;
|
|
393
|
-
|
|
533
|
+
// 带出人设正文(plugins[0] 的人设插件的 config.prefix),供 stale 体检:
|
|
534
|
+
// 旧版本召唤写入的团队预设没有桥接段,读出来后前端能标「需更新」。
|
|
535
|
+
let personaText = null;
|
|
536
|
+
const plugins = config.get("plugins", true);
|
|
537
|
+
if (isSeq(plugins) && plugins.items.length > 0 && isMap(plugins.items[0])) {
|
|
538
|
+
const personaConfig = plugins.items[0].get("config", true);
|
|
539
|
+
const prefix = personaConfig ? personaConfig.get("prefix", true) : null;
|
|
540
|
+
if (prefix && typeof prefix.value === "string") personaText = prefix.value;
|
|
541
|
+
}
|
|
542
|
+
rows.push({
|
|
543
|
+
presetId: String(mapValue(entry, "id")),
|
|
544
|
+
plugin: presetId.slice(EXPERT_PRESET_PREFIX.length),
|
|
545
|
+
personaText,
|
|
546
|
+
});
|
|
394
547
|
}
|
|
395
548
|
return rows;
|
|
396
549
|
}
|
|
@@ -578,8 +731,9 @@ export function removeExpertPreset(content, plugin) {
|
|
|
578
731
|
// 持久化在 ~/.dsh/wb-market/experts/recent.json:三类各存一份轻量记录
|
|
579
732
|
// (skill: 技能名+来源目录名;mcp: serverName+作用域;expert: plugin+召唤预设名)。
|
|
580
733
|
// 只在「可观察的使用动作」发生时写入:召唤/送还专家、召唤菜单里选中一项(由客户端
|
|
581
|
-
// 显式上报)。上限各
|
|
582
|
-
|
|
734
|
+
// 显式上报)。上限各 40 条(召唤菜单每屏 6 行 + 滚动,排序需要较深的最近记录池),
|
|
735
|
+
// 新的顶到最前,去重按主键。文件损坏静默重置(纯增值数据)。
|
|
736
|
+
const RECENT_LIMIT = 40;
|
|
583
737
|
function recentPath() {
|
|
584
738
|
return join(cacheDir(), "recent.json");
|
|
585
739
|
}
|
|
@@ -596,7 +750,22 @@ async function readRecentRaw() {
|
|
|
596
750
|
}
|
|
597
751
|
async function writeRecentRaw(data) {
|
|
598
752
|
await mkdir(cacheDir(), { recursive: true });
|
|
599
|
-
|
|
753
|
+
// OPT-08:原子写。直接覆盖写若中途断电/被杀,会留下截断的 JSON——readRecentRaw
|
|
754
|
+
// 虽然会静默重置(只丢「最近使用」排序、不至于崩),但没必要留着这个窗口。
|
|
755
|
+
//
|
|
756
|
+
// 这里刻意用「临时文件 + rename」而不引 @deepseek-ai/dsh-atomic-write:那是宿主提供的
|
|
757
|
+
// peer 依赖,本包 node_modules 里没有它,而 expert-market.js 会被 test/expert-manual.mjs
|
|
758
|
+
// 直接 import —— 加静态导入会让本地测试当场 ERR_MODULE_NOT_FOUND。
|
|
759
|
+
// 同目录 rename 在同一文件系统内是原子操作,足够。
|
|
760
|
+
const target = recentPath();
|
|
761
|
+
const tmp = `${target}.${process.pid}.${Date.now().toString(36)}.tmp`;
|
|
762
|
+
try {
|
|
763
|
+
await writeFile(tmp, JSON.stringify(data, null, 1));
|
|
764
|
+
await rename(tmp, target);
|
|
765
|
+
} catch (e) {
|
|
766
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
767
|
+
throw e;
|
|
768
|
+
}
|
|
600
769
|
}
|
|
601
770
|
/** 记录一次使用。row 携带菜单展示所需的最小字段;kind ∈ skill|mcp|expert。 */
|
|
602
771
|
export async function recordExpertRecent(kind, row) {
|
package/lib/index.js
CHANGED
|
@@ -13,8 +13,8 @@ import { collectImportLocations, missingEnvNames, resolveImportLocation } from "
|
|
|
13
13
|
import { deriveMcpPhase, formatMcpLog, sanitizeMcpLog } from "./mcp-observability.js";
|
|
14
14
|
import { allMcpEntries, entriesForName, projectToolSchema, revisionOf, toolInventory } from "./mcp-registry.js";
|
|
15
15
|
import { WHATS_NEW } from "./whats-new.js";
|
|
16
|
-
import { wbConnectorCatalog, wbConnectorPreview, wbSkillCatalog, wbSkillDetail,
|
|
17
|
-
import { installExpertSkills, listExpertRecent, listExpertSkills, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
|
|
16
|
+
import { wbConnectorCatalog, wbConnectorPreview, wbSkillCatalog, wbSkillDetail, wbSkillInstall, wbSkillInstalled, wbSkillUninstall } from "./wb-market.js";
|
|
17
|
+
import { hasTeamBridge, installExpertSkills, listExpertRecent, listExpertSkills, prepareExpertPersona, readExpertPresets, recordExpertRecent, removeExpertPreset, uninstallExpertSkills, uninstallTeamMemberPersonas, wbExpertCatalog, wbExpertDetail, writeExpertPreset } from "./expert-market.js";
|
|
18
18
|
import { attachPersona, detachPersona, listPersonas, reconcilePersonas } from "./session-persona.js";
|
|
19
19
|
import { scanSessionLedger } from "./tool-ledger.js";
|
|
20
20
|
import { evaluateEnvExpression, jsExpressionToTemplate } from "./workspace-config.js";
|
|
@@ -1470,13 +1470,23 @@ let McpManagerGateway = (() => {
|
|
|
1470
1470
|
if (!sessionId) throw new Error("缺少 sessionId");
|
|
1471
1471
|
if (!plugin) throw new Error("缺少 plugin");
|
|
1472
1472
|
const detail = await wbExpertDetail({ plugin });
|
|
1473
|
+
// 团队型:成员人设先落盘,主理人人设末尾追加 DSH 桥接段(与召唤预设同一套)。
|
|
1474
|
+
const prepared = await prepareExpertPersona(detail, {
|
|
1475
|
+
onLog: (message) => this.ctx.logger?.debug?.(`wb-expert: ${message}`),
|
|
1476
|
+
});
|
|
1473
1477
|
const result = attachPersona(this.ctx, sessionId, {
|
|
1474
1478
|
plugin,
|
|
1475
1479
|
name: detail.name,
|
|
1476
|
-
personaText:
|
|
1480
|
+
personaText: prepared.personaText,
|
|
1477
1481
|
});
|
|
1478
1482
|
await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: `session-${plugin}` }).catch(() => {});
|
|
1479
|
-
return {
|
|
1483
|
+
return {
|
|
1484
|
+
...result,
|
|
1485
|
+
profession: detail.profession,
|
|
1486
|
+
expertType: detail.expertType,
|
|
1487
|
+
members: detail.members || [],
|
|
1488
|
+
memberNotes: prepared.memberResult?.notes || [],
|
|
1489
|
+
};
|
|
1480
1490
|
}
|
|
1481
1491
|
async expertDetach(payload) {
|
|
1482
1492
|
const sessionId = String(payload?.sessionId || "").trim();
|
|
@@ -1499,6 +1509,10 @@ let McpManagerGateway = (() => {
|
|
|
1499
1509
|
// 详情即预览:召唤写盘前拉一次真实人设与技能清单,失败则整体不动。
|
|
1500
1510
|
const detail = await wbExpertDetail({ plugin });
|
|
1501
1511
|
const withSkills = payload?.withSkills !== false && detail.skills.length > 0;
|
|
1512
|
+
// 团队型:成员人设先落盘(人设不进 preset,避免每轮白付),主理人人设追加桥接段。
|
|
1513
|
+
const prepared = await prepareExpertPersona(detail, {
|
|
1514
|
+
onLog: (message) => this.ctx.logger?.debug?.(`wb-expert: ${message}`),
|
|
1515
|
+
});
|
|
1502
1516
|
// 技能先落盘(失败只影响技能,不阻断召唤),预设行再走唯一写链(文件锁 + 版本校验 + 原子写)。
|
|
1503
1517
|
let skillResult = { installed: [], notes: [] };
|
|
1504
1518
|
if (withSkills) {
|
|
@@ -1513,7 +1527,7 @@ let McpManagerGateway = (() => {
|
|
|
1513
1527
|
displayName: detail.name,
|
|
1514
1528
|
profession: detail.profession,
|
|
1515
1529
|
description: detail.description,
|
|
1516
|
-
personaText:
|
|
1530
|
+
personaText: prepared.personaText,
|
|
1517
1531
|
});
|
|
1518
1532
|
if (written.content === state.content) return { mode: "unchanged", presetId: written.presetId };
|
|
1519
1533
|
await writePatch(state, written.content);
|
|
@@ -1521,14 +1535,26 @@ let McpManagerGateway = (() => {
|
|
|
1521
1535
|
});
|
|
1522
1536
|
// 召唤成功即记入最近使用(送还由 listExpertRecent 对账剔除)。记录失败不阻断召唤。
|
|
1523
1537
|
await recordExpertRecent("expert", { plugin, name: detail.name, profession: detail.profession, presetId: result.presetId }).catch(() => {});
|
|
1538
|
+
const teamMembers = prepared.memberResult?.installed || [];
|
|
1539
|
+
// upsert 后核对人设正文是否真带桥接段(团队型);不带则提示更新结果不完整。
|
|
1540
|
+
// 正常路径 prepared.personaText 已拼桥接段,这个分支只在异常数据时触发。
|
|
1541
|
+
const staleBridge = detail.expertType === "team" && !hasTeamBridge(prepared.personaText);
|
|
1524
1542
|
return {
|
|
1525
1543
|
presetId: result.presetId,
|
|
1526
1544
|
presetMode: result.mode,
|
|
1545
|
+
expertType: detail.expertType,
|
|
1546
|
+
members: detail.members || [],
|
|
1527
1547
|
summonedSkills: skillResult.installed,
|
|
1528
1548
|
skillNotes: skillResult.notes,
|
|
1549
|
+
summonedMembers: teamMembers,
|
|
1550
|
+
memberNotes: prepared.memberResult?.notes || [],
|
|
1551
|
+
staleBridge,
|
|
1529
1552
|
note: `已召唤「${detail.name}」:新会话选择预设「WorkBuddy 专家 · ${detail.name}」即专家人设接管`
|
|
1553
|
+
+ (result.mode === "unchanged" ? "(预设已是最新,未改动)" : "")
|
|
1554
|
+
+ (result.mode === "updated" ? "(旧预设行已刷新为人设最新版)" : "")
|
|
1555
|
+
+ (teamMembers.length ? `;团队成员人设 ${teamMembers.length} 份已落盘,主理人按编制用 subagent 调度` : "")
|
|
1530
1556
|
+ (skillResult.installed.length ? `;捆绑技能 ${skillResult.installed.length} 个已装进 ~/.agents/skills/` : "")
|
|
1531
|
-
+ (skillResult.notes.length ? `;注意:${skillResult.notes.join(";")}` : ""),
|
|
1557
|
+
+ ([...(prepared.memberResult?.notes || []), ...skillResult.notes].length ? `;注意:${[...(prepared.memberResult?.notes || []), ...skillResult.notes].join(";")}` : ""),
|
|
1532
1558
|
};
|
|
1533
1559
|
}
|
|
1534
1560
|
async wbExpertDismiss(payload) {
|
|
@@ -1548,17 +1574,29 @@ let McpManagerGateway = (() => {
|
|
|
1548
1574
|
} catch (error) {
|
|
1549
1575
|
this.ctx.logger?.warn?.(`wb-expert: 预设已摘除,但技能清理失败:${error?.message ?? error}`);
|
|
1550
1576
|
}
|
|
1551
|
-
|
|
1577
|
+
// 团队型:成员人设目录同样按 plugin 隔离,一并清除。
|
|
1578
|
+
let removedTeam = null;
|
|
1579
|
+
try {
|
|
1580
|
+
removedTeam = await uninstallTeamMemberPersonas(plugin);
|
|
1581
|
+
} catch (error) {
|
|
1582
|
+
this.ctx.logger?.warn?.(`wb-expert: 预设已摘除,但成员人设清理失败:${error?.message ?? error}`);
|
|
1583
|
+
}
|
|
1584
|
+
if (result.mode === "absent" && !removedSkills.length && !removedTeam) throw new Error("本机没有召唤过这个专家(profile patch 里没有对应预设行)");
|
|
1552
1585
|
return {
|
|
1553
1586
|
removedSkills,
|
|
1587
|
+
removedTeam,
|
|
1554
1588
|
note: `已送还「${plugin}」`
|
|
1555
1589
|
+ (result.mode === "removed" ? ":召唤预设已从 profile patch 摘除(热加载,无需重启)" : ":本没有召唤预设")
|
|
1556
|
-
+ (removedSkills.length ? `;捆绑技能已清理 ${removedSkills.length} 个目录` : "")
|
|
1590
|
+
+ (removedSkills.length ? `;捆绑技能已清理 ${removedSkills.length} 个目录` : "")
|
|
1591
|
+
+ (removedTeam ? ";团队成员人设已清理" : ""),
|
|
1557
1592
|
};
|
|
1558
1593
|
}
|
|
1559
1594
|
async wbExpertInstalled() {
|
|
1560
1595
|
// 已召唤 = profile patch 里的召唤预设行;已装技能 = ~/.agents/skills 的 wbexpert__ 目录。
|
|
1561
1596
|
// patch 读走与 managedState 同一条解析(readPatch),但不持写锁——只读快照。
|
|
1597
|
+
// 团队行做 stale 体检:人设正文缺桥接段 = 旧版本召唤写入的行,新会话选它会调
|
|
1598
|
+
// 不存在的 TeamCreate/Agent(成员开不出来)。标记桥接判定放前端(需要市场目录的
|
|
1599
|
+
// expertType),这里只把 personaText 原样带出去。
|
|
1562
1600
|
const patch = await readPatch(this.ctx);
|
|
1563
1601
|
return { presets: readExpertPresets(patch.content), skills: await listExpertSkills() };
|
|
1564
1602
|
}
|
package/lib/mcp-config.js
CHANGED
|
@@ -387,6 +387,10 @@ function makeEntry(doc, spec, id = `mcp-${spec.name}`) {
|
|
|
387
387
|
function parsePatch(content) {
|
|
388
388
|
const doc = parseDocument(content || '')
|
|
389
389
|
if (doc.errors.length) throw new Error(`MCP patch YAML 无法解析:${doc.errors[0].message}`)
|
|
390
|
+
// 0 字节的文件、只有注释的文件,解析出来是 `contents: null` 而 errors 是 0 条——语法上
|
|
391
|
+
// 完全合法。以前这里直接报「必须是 YAML 列表」,于是 `managedState` 整块失败、MCP 面板
|
|
392
|
+
// 全白,而用户手上的文件根本没有写错。补一个空 seq 就能继续;String(doc) 会原样保留注释。
|
|
393
|
+
if (doc.contents === null) doc.contents = new YAMLSeq()
|
|
390
394
|
if (!isSeq(doc.contents)) throw new Error('MCP patch 必须是 YAML 列表')
|
|
391
395
|
return doc
|
|
392
396
|
}
|
package/lib/tool-ledger.js
CHANGED
|
@@ -25,6 +25,31 @@ const MCP_PREFIX = "mcp__";
|
|
|
25
25
|
/** 每处理这么多文件让出一次事件循环,避免大库扫描把宿主卡住。 */
|
|
26
26
|
const YIELD_EVERY = 16;
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* `zlib.zstdDecompressSync` 是 Node >= 22.15 才有的 API。缺失时**绝对不能**静默把每个帧
|
|
30
|
+
* 都当成「损坏」吞掉:那会让工具裁缝表现为「扫到了 N 个会话、读到了 N 个日志、调用数整列 0」,
|
|
31
|
+
* 而界面上看不到任何原因 —— 与「只看零调用」筛选的界面表现完全一致,无法自证清白。
|
|
32
|
+
* 所以这里探测一次,让失败有名字(OPT-01)。
|
|
33
|
+
*/
|
|
34
|
+
const ZSTD_SUPPORTED = typeof zlib.zstdDecompressSync === "function";
|
|
35
|
+
const ZSTD_UNAVAILABLE_MESSAGE =
|
|
36
|
+
"zstd 解压不可用:当前 Node 不支持 zlib.zstdDecompressSync(需 Node >= 22.15)";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 单个日志的**落盘体积**上限(OPT-06)。完整链路是「整块读入 → 逐帧解压 → Buffer.concat
|
|
40
|
+
* → toString(utf8) → split(换行)」,峰值内存约为压缩体积的 5~10 倍;没有上限意味着一个异常
|
|
41
|
+
* 长的会话日志就能拖垮主进程。64 MB 相对当前最大单文件(4.4 MB)有十四倍冗余,正常使用碰不到。
|
|
42
|
+
*/
|
|
43
|
+
const MAX_LOG_BYTES = 64 * 1024 * 1024;
|
|
44
|
+
/** 解压文本缓存预算:按**字节**控制而非只按条数(见 textCache 注释)。 */
|
|
45
|
+
const TEXT_CACHE_MAX_BYTES = 128 * 1024 * 1024;
|
|
46
|
+
const TEXT_CACHE_MAX_ENTRIES = 32;
|
|
47
|
+
|
|
48
|
+
/** 把字节数格式化成 MB 文案,只用于诊断信息。 */
|
|
49
|
+
function formatMB(bytes) {
|
|
50
|
+
return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
/**
|
|
29
54
|
* 解压**多帧** zstd 缓冲(宽容版)。与 dsh-forget-session 的 zstdDecompressAllFrames 同思路:
|
|
30
55
|
* 按 zstd magic(0x28B52FFD)切帧逐帧解压;单帧文件行为不变。
|
|
@@ -33,6 +58,9 @@ const YIELD_EVERY = 16;
|
|
|
33
58
|
* @returns {{ text: string, frames: number, failedFrames: number }}
|
|
34
59
|
*/
|
|
35
60
|
function zstdDecompressAllFramesLenient(buf) {
|
|
61
|
+
// 硬守卫(OPT-01):Node 太旧时**必须**抛出一个有名字的错误,让 scanSessionLedger 的
|
|
62
|
+
// failures() 捕获并写进 failedFileSamples 上屏,而不是逐帧 try/catch 各自吞掉、静默全 0。
|
|
63
|
+
if (!ZSTD_SUPPORTED) throw new Error(ZSTD_UNAVAILABLE_MESSAGE);
|
|
36
64
|
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
37
65
|
const starts = [];
|
|
38
66
|
let idx = buf.indexOf(MAGIC, 0);
|
|
@@ -54,6 +82,65 @@ function zstdDecompressAllFramesLenient(buf) {
|
|
|
54
82
|
return { text: Buffer.concat(parts).toString("utf8"), frames: starts.length, failedFrames };
|
|
55
83
|
}
|
|
56
84
|
|
|
85
|
+
/**
|
|
86
|
+
* 解压文本的跨调用缓存(OPT-03)。收益主体在这里:扫描耗时绝大部分花在「读盘 + 逐帧解压
|
|
87
|
+
* + utf8 解码」上,JSON 解析只占小头。
|
|
88
|
+
*
|
|
89
|
+
* 两条纪律:
|
|
90
|
+
* 1. 键是**日志绝对路径**,戳是 `size:mtimeMs`。日志是追加写,写必然同时抬升 size 与 mtime,
|
|
91
|
+
* 所以戳一变就失效,不会拿到陈旧数据;反过来,戳没变时内容按定义没有变化。
|
|
92
|
+
* 2. 淘汰按**字节预算**而不是只按条数。按报告里 32 条 × 解压后 15 MB 估算是 480 MB 峰值,
|
|
93
|
+
* 在宿主主进程里不可接受;这里 128 MB 封顶,超过就按 LRU 丢到预算以内。
|
|
94
|
+
* 只缓存 scanSessionLedger 需要的那点数据(解压文本 + 帧统计),不缓存任何调用记录。
|
|
95
|
+
*/
|
|
96
|
+
const textCache = new Map(); // logPath -> { stamp, bytes, text, frames, failedFrames }
|
|
97
|
+
let textCacheBytes = 0;
|
|
98
|
+
|
|
99
|
+
function dropCacheEntry(logPath) {
|
|
100
|
+
const entry = textCache.get(logPath);
|
|
101
|
+
if (entry) {
|
|
102
|
+
textCacheBytes -= entry.bytes;
|
|
103
|
+
textCache.delete(logPath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function rememberCacheEntry(logPath, entry) {
|
|
108
|
+
dropCacheEntry(logPath);
|
|
109
|
+
textCache.set(logPath, entry);
|
|
110
|
+
textCacheBytes += entry.bytes;
|
|
111
|
+
// Map 的迭代顺序即插入顺序,队首就是最久未使用的一条。
|
|
112
|
+
while (textCache.size > TEXT_CACHE_MAX_ENTRIES || textCacheBytes > TEXT_CACHE_MAX_BYTES) {
|
|
113
|
+
const oldest = textCache.keys().next();
|
|
114
|
+
if (oldest.done) break;
|
|
115
|
+
dropCacheEntry(oldest.value);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 读取并解压一个日志文件,命中缓存则完全跳过 IO 与解压。
|
|
121
|
+
* @param {string} logPath - 日志绝对路径。
|
|
122
|
+
* @param {{ size: number, mtimeMs: number }} st - 已取到的 stat 结果(不额外产生 IO)。
|
|
123
|
+
* @returns {Promise<{ text: string, frames: number, failedFrames: number }>}
|
|
124
|
+
*/
|
|
125
|
+
async function readLogTextCached(logPath, st) {
|
|
126
|
+
const stamp = `${st.size}:${st.mtimeMs}`;
|
|
127
|
+
const hit = textCache.get(logPath);
|
|
128
|
+
if (hit && hit.stamp === stamp) {
|
|
129
|
+
// 触碰一次,让它在 LRU 里回到队尾。
|
|
130
|
+
textCache.delete(logPath);
|
|
131
|
+
textCache.set(logPath, hit);
|
|
132
|
+
return hit;
|
|
133
|
+
}
|
|
134
|
+
const raw = await readFile(logPath);
|
|
135
|
+
const value = logPath.endsWith(".zstd")
|
|
136
|
+
? zstdDecompressAllFramesLenient(raw)
|
|
137
|
+
: { text: raw.toString("utf8"), frames: 0, failedFrames: 0 };
|
|
138
|
+
const entry = { stamp, bytes: Buffer.byteLength(value.text, "utf8"), ...value };
|
|
139
|
+
// 单条就超出总预算的怪物不缓存(否则会把整个缓存挤空,还占着内存)。
|
|
140
|
+
if (entry.bytes <= TEXT_CACHE_MAX_BYTES) rememberCacheEntry(logPath, entry);
|
|
141
|
+
return value;
|
|
142
|
+
}
|
|
143
|
+
|
|
57
144
|
/**
|
|
58
145
|
* DSH 数据根(`~/.dsh` 的上一级)。优先 `DSH_SKILL_MCP_BOARD_HOME`(测试隔离用,与
|
|
59
146
|
* dsh-forget-session 的 `DSH_FORGET_SESSION_HOME` 同一惯用法),否则 OS 家目录。
|
|
@@ -115,6 +202,7 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
115
202
|
const usage = new Map(); // toolName -> record
|
|
116
203
|
let filesRead = 0;
|
|
117
204
|
let filesSkippedStale = 0;
|
|
205
|
+
let filesSkippedOversize = 0;
|
|
118
206
|
let failedFiles = 0;
|
|
119
207
|
let failedFileSamples = [];
|
|
120
208
|
let sessionDirs = 0;
|
|
@@ -160,32 +248,35 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
160
248
|
}
|
|
161
249
|
for (const logName of logFiles) {
|
|
162
250
|
const logPath = join(sessDir, logName);
|
|
251
|
+
let st;
|
|
163
252
|
try {
|
|
164
253
|
// mtime 早于窗口起点 → 里面不可能有窗口内事件,整个文件跳过。
|
|
165
254
|
// 依据:日志是追加写,追加必然抬升 mtime。
|
|
166
|
-
|
|
167
|
-
if (st.mtimeMs < cutoffMs) {
|
|
168
|
-
filesSkippedStale += 1;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
255
|
+
st = await stat(logPath);
|
|
171
256
|
} catch {
|
|
172
257
|
failures(`${logPath}: stat failed`);
|
|
173
258
|
continue;
|
|
174
259
|
}
|
|
175
|
-
|
|
260
|
+
if (st.mtimeMs < cutoffMs) {
|
|
261
|
+
filesSkippedStale += 1;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// 尺寸闸门放在读盘之前(OPT-06):超限就降级跳过,并把原因交给 failures()
|
|
265
|
+
// 上屏,而不是先整块读进来再发现内存不够。
|
|
266
|
+
if (st.size > MAX_LOG_BYTES) {
|
|
267
|
+
filesSkippedOversize += 1;
|
|
268
|
+
failures(`${logPath}: 日志 ${formatMB(st.size)} 超过 ${formatMB(MAX_LOG_BYTES)} 上限,已跳过`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
let loaded;
|
|
176
272
|
try {
|
|
177
|
-
|
|
178
|
-
if (logName.endsWith(".zstd")) {
|
|
179
|
-
const out = zstdDecompressAllFramesLenient(raw);
|
|
180
|
-
text = out.text;
|
|
181
|
-
failedFrames += out.failedFrames;
|
|
182
|
-
} else {
|
|
183
|
-
text = raw.toString("utf8");
|
|
184
|
-
}
|
|
273
|
+
loaded = await readLogTextCached(logPath, st);
|
|
185
274
|
} catch (error) {
|
|
186
275
|
failures(`${logPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
187
276
|
continue;
|
|
188
277
|
}
|
|
278
|
+
const text = loaded.text;
|
|
279
|
+
failedFrames += loaded.failedFrames;
|
|
189
280
|
filesRead += 1;
|
|
190
281
|
let dirHadCall = false;
|
|
191
282
|
for (const line of text.split("\n")) {
|
|
@@ -314,9 +405,15 @@ export async function scanSessionLedger({ days, mcpServerNames, schemas, disable
|
|
|
314
405
|
sessionsWithCalls,
|
|
315
406
|
filesRead,
|
|
316
407
|
filesSkippedStale,
|
|
408
|
+
filesSkippedOversize,
|
|
317
409
|
failedFiles,
|
|
318
410
|
failedFileSamples,
|
|
319
411
|
failedFrames,
|
|
412
|
+
// OPT-01/OPT-02:把「解码能力」和「失败原因」显式交给上层,界面才有东西可显示。
|
|
413
|
+
zstdSupported: ZSTD_SUPPORTED,
|
|
414
|
+
// 界面在 zstd 降级时会提示「升级到 22.15 以上」,得能说出用户当前跑的是什么版本,
|
|
415
|
+
// 否则那句提示等于没说。取宿主进程自己的版本(不是 package.json 的 engines 声明)。
|
|
416
|
+
nodeVersion: process.versions.node,
|
|
320
417
|
totalCalls,
|
|
321
418
|
totalResults,
|
|
322
419
|
totalErrors,
|
package/lib/whats-new.js
CHANGED
|
@@ -242,4 +242,50 @@ export const WHATS_NEW = [
|
|
|
242
242
|
},
|
|
243
243
|
],
|
|
244
244
|
},
|
|
245
|
+
{
|
|
246
|
+
version: '1.9.3',
|
|
247
|
+
items: [
|
|
248
|
+
{
|
|
249
|
+
text: '召唤菜单的「技能 / MCP / 专家」子菜单升级为**全量列表 + 滚动浏览**:每屏固定显示 6 行,超出的往下滚(细滚动条跟随主题);「管理技能/MCP/专家…」按钮固定在底部,不再被列表滚走。以前每类只列前 6 条,第 7 个技能在菜单里根本找不到',
|
|
250
|
+
where: '会话输入框工具行右侧的看板小图标',
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
text: '子菜单按**最近使用**排序:你刚用过的条目自动置顶并带「最近」标记(首屏 6 个),其余按原有顺序排在后面。最近使用记录池从 8 条加深到 40 条,排序更贴合真实使用习惯',
|
|
254
|
+
where: '召唤菜单 → 技能 / MCP / 专家',
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
text: '修复「换了专家,会话最上方还显示第一个专家」:会话头那条**预设标签**读的是会话创建时的事实、换专家不会变(宿主只读)。现在挂上专家后它会自动让位,改由一枚显示**当前真正生效**专家的徽章接管,并随换随新',
|
|
258
|
+
where: '会话标题栏(挂专家后)',
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
text: '修复专家显示的「三名不一致」:以前预设标签、输入框 chip、会话头三处各查一次状态,换完专家要等重挂才刷新。现在挂/换/摘后立即同帧同步,另有 5 秒兜底对账(覆盖从预设选择器等别的入口挂上的专家)',
|
|
262
|
+
where: '会话标题栏 + 输入框专家 chip',
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
text: '专家挂载不再走「预设通道」:那条路会在会话投影里写下一个**永不更新**的专家名,是上面那个「顶栏显示 A、实际生效 B」的根因。现在统一走会话级人设通道——随时挂/换/摘,显示与实效从此一致',
|
|
266
|
+
where: '召唤菜单 → 专家',
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
version: '1.9.4',
|
|
272
|
+
items: [
|
|
273
|
+
{
|
|
274
|
+
text: '**团队型专家可以召唤了**:以前 53 个专家团(软件开发团队、内容工厂等)在列表里只显示「团队型暂只读」,现在点「召唤专家团」即可用。召唤时除了写入主理人预设,还会把每位成员的人设各自落成一份本地文件(`~/.dsh/wb-market/experts/teams/<专家>/`),送还时整体清除',
|
|
275
|
+
where: '专家 → 任一标注「专家团」的卡片',
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
text: '团队主理人会**按编制自动调度成员**:WorkBuddy 的团队人设原本依赖 `TeamCreate` 与 `Agent(name=…)` 两个 DSH 没有的工具。现在召唤时会往主理人人设末尾追加一段运行时适配,把这两类指令映射成 DSH 的 `subagent`(成员人设先 read 出来拼进 prompt),并保留「不得代写成员产出、成员之间不直连」等原始铁律',
|
|
279
|
+
where: '专家 → 专家团详情弹层(「团队编制」区块会列出每位成员与职责)',
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
text: '成员人设**不计入系统提示**:5 份人设若全塞进预设,每一轮请求都要为它们白付上下文费。现在只落盘、由主理人调度时按需读取,预设里仍然只有主理人一份人设',
|
|
283
|
+
where: '—(实现细节,影响每轮 token 成本)',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
text: '修复「工具裁缝对所有工具的检测数量全为 0」:那其实是筛选口径造成的错觉——表格默认开着「只看死工具」,屏幕上自然整列 0。现在**默认显示全部工具与真实调用次数**,零调用的行用底色标出;按钮改成「只看零调用(N)」按需开启,顶部常驻回显「显示 N/M 个工具」,不会再被读成「检测全坏」',
|
|
287
|
+
where: '工具裁缝标签页',
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
245
291
|
]
|
package/lib/workspace-runtime.js
CHANGED
|
@@ -1156,7 +1156,9 @@ function installAgentRuntime(ctx) {
|
|
|
1156
1156
|
};
|
|
1157
1157
|
// 生命周期归属先建立、再订阅事件:反过来的话,effect() 抛出(fiber 已 inactive)就会留下一个
|
|
1158
1158
|
// 被死插件永久持有的监听器,而且再也没有重新安装的机会。
|
|
1159
|
-
|
|
1159
|
+
// 不接 ctx.effect 的返回值:cleanup 由 fiber 持有,本函数另外把同一个 cleanup 交回给
|
|
1160
|
+
// 调用方(末尾 return cleanup),两个入口都幂等,没必要再存一份 disposer。
|
|
1161
|
+
ctx.effect(() => () => cleanup(), "mcp-manager.agentRuntime");
|
|
1160
1162
|
activeGenerationByRoot.set(appRoot(ctx), generation);
|
|
1161
1163
|
try {
|
|
1162
1164
|
disposeAgentCreated = ctx.on("agent/created", async (payload) => {
|