dsh-plugin-t-expert 0.2.4 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/i18n.js CHANGED
@@ -20,11 +20,27 @@ const ZH = {
20
20
  "error.specsTooMany": "一次最多召唤 {limit} 位专家",
21
21
  "error.specInvalid": "experts[{index}] 需要 expert 与 task 两个字段",
22
22
  "error.settingsMissing": "T专家 设置段尚未注册",
23
- "list.headerAll": "T专家 花名册(已启用 {total} 位,覆盖 {divisions} 个分区):",
24
- "list.headerOne": "分区 {division} 的已启用专家({count} 位):",
23
+ "error.customSlugInvalid": "slug 只能用 a-z、0-9 与连字符,且以字母或数字开头:\"{slug}\"",
24
+ "error.customSlugTaken": "slug「{slug}」已被内置或自建专家占用",
25
+ "error.customDivisionInvalid": "分类目录名只能用 a-z、0-9、连字符与点,且以字母或数字开头(中文请填在「显示名」里):\"{division}\"",
26
+ "error.categoryOfficial": "「{division}」是官方分类:名字与存亡都跟随名册同步,只能改自建分类",
27
+ "error.categoryExists": "分类「{division}」已经存在",
28
+ "error.categoryMissing": "找不到自建分类「{division}」",
29
+ "error.categoryNotEmpty": "分类「{division}」下还有 {count} 位自建专家:先把它们移到别的分类或删掉,再删这个分类",
30
+ "error.customNameTaken": "名称「{name}」与已有专家「{other}」重复;重名会让两位都无法被召唤",
31
+ "error.customNameEmpty": "名称不能为空",
32
+ "error.customDescriptionEmpty": "简介不能为空(缺简介的专家不会进名册)",
33
+ "error.customBodyEmpty": "人格正文不能为空(专家靠它工作)",
34
+ "error.customTextTooLong": "{field} 超过 {limit} 个字符",
35
+ "error.customNotCustom": "「{slug}」不是自建专家;内置专家由名册同步维护,请用「复制为自建」再改",
36
+ "error.customMissing": "找不到自建专家「{slug}」",
37
+ "error.customConflict": "这位自建专家已被别处改动,请关掉编辑框重新打开再改",
38
+ "error.customRootMissing": "自建专家目录未配置(customRoot)",
39
+ "list.headerAll": "T专家 花名册(已启用 {total} 位,覆盖 {divisions} 个分类):",
40
+ "list.headerOne": "分类 {division} 的已启用专家({count} 位):",
25
41
  "list.emptyAll": "还没有启用任何专家。请在 设置 → T专家 里启用。",
26
- "list.emptyOne": "分区「{division}」下没有已启用的专家。",
27
- "list.unknownDivision": "找不到分区「{query}」。可用分区:{divisions}",
42
+ "list.emptyOne": "分类「{division}」下没有已启用的专家。",
43
+ "list.unknownDivision": "找不到分类「{query}」。可用分类:{divisions}",
28
44
  "list.hint": "用 summon_t_expert(expert, task) 召唤其中一位;批量用 summon_t_experts。",
29
45
  "summon.batchHeader": "批量召唤 {total} 位专家:成功 {ok} / 失败 {failed}",
30
46
  "summon.itemOk": "## {expert}\n{answer}",
@@ -49,6 +65,22 @@ const EN = {
49
65
  "error.specsTooMany": "At most {limit} experts per call",
50
66
  "error.specInvalid": "experts[{index}] requires both expert and task",
51
67
  "error.settingsMissing": "T Expert settings section is not registered yet",
68
+ "error.customSlugInvalid": "slug may only use a-z, 0-9 and hyphens, starting with a letter or digit: \"{slug}\"",
69
+ "error.categoryOfficial": "\"{division}\" is an official division: its name and existence follow the built-in roster sync, so only custom divisions can be changed",
70
+ "error.categoryExists": "Division \"{division}\" already exists",
71
+ "error.categoryMissing": "No custom division named \"{division}\"",
72
+ "error.categoryNotEmpty": "Division \"{division}\" still has {count} custom expert(s): move or delete them first, then delete the division",
73
+ "error.customSlugTaken": "slug \"{slug}\" is already taken by a built-in or custom expert",
74
+ "error.customDivisionInvalid": "A division directory name may only use a-z, 0-9, dots and hyphens, starting with a letter or digit (put the Chinese in the display name): \"{division}\"",
75
+ "error.customNameTaken": "Name \"{name}\" duplicates existing expert \"{other}\"; duplicates make both unsummonable",
76
+ "error.customNameEmpty": "Name cannot be empty",
77
+ "error.customDescriptionEmpty": "Description cannot be empty (experts without one never enter the roster)",
78
+ "error.customBodyEmpty": "Persona body cannot be empty (it is what the expert works from)",
79
+ "error.customTextTooLong": "{field} exceeds {limit} characters",
80
+ "error.customNotCustom": "\"{slug}\" is not a custom expert; built-in experts come from the synced roster (copy it as custom first)",
81
+ "error.customMissing": "Custom expert not found: \"{slug}\"",
82
+ "error.customConflict": "This custom expert changed elsewhere; close and reopen the editor",
83
+ "error.customRootMissing": "Custom expert root is not configured (customRoot)",
52
84
  "list.headerAll": "T Expert roster ({total} enabled experts across {divisions} divisions):",
53
85
  "list.headerOne": "Enabled experts in division {division} ({count}):",
54
86
  "list.emptyAll": "No expert is enabled yet. Enable some in Settings → T Expert.",
@@ -84,7 +116,7 @@ export function readLocale(ctx) {
84
116
 
85
117
  /**
86
118
  * 按语言取专家显示名与简介。
87
- * 中文优先用侧车目录的译文(expert.nameZh / descriptionZh),缺失则回退上游英文。
119
+ * 中文优先用侧车目录的译文(expert.nameZh / descriptionZh),缺失则回退英文原文。
88
120
  */
89
121
  export function localized(expert, locale) {
90
122
  if (locale === "en") {
package/lib/index.js CHANGED
@@ -11,31 +11,43 @@
11
11
  */
12
12
  import { homedir } from "node:os";
13
13
  import { existsSync, readFileSync } from "node:fs";
14
- import { stat } from "node:fs/promises";
14
+ import { stat, rmdir } from "node:fs/promises";
15
15
  import { dirname, join } from "node:path";
16
16
  import schema from "@deepseek-ai/schemastery";
17
17
  import { defineTool } from "@deepseek-ai/dsh-tools";
18
18
  import {
19
+ CUSTOM_DIVISION,
20
+ CUSTOM_LIMITS,
19
21
  DEFAULT_DIVISIONS,
20
22
  catalogDivisions,
23
+ customExpertPath,
21
24
  divisionOf,
25
+ fileFingerprint,
22
26
  groupByDivision,
27
+ isValidDivision,
28
+ isValidSlug,
23
29
  loadCatalog,
24
30
  mapPool,
31
+ normalizedKey,
25
32
  readPersona,
33
+ removeCustomExpert,
26
34
  resolveExpert,
27
35
  sanitize,
36
+ saveCustomLabel,
37
+ serializeCustomExpert,
28
38
  truncate,
39
+ writeFileAtomic,
29
40
  } from "./catalog.js";
30
41
  import { localized, NS, readLocale, renderList, renderSummonResults, t } from "./i18n.js";
31
42
  import { seedData } from "./bootstrap.js";
32
43
  import { registerTeamCommand } from "./command.js";
33
44
  import { createSquadService } from "./squads.js";
45
+ import { SNAPSHOT_DIR } from "./bootstrap.js";
34
46
  import { checkPlan } from "./plan-check.js";
35
47
  import { findTeamByCaptain } from "./teams/state.js";
36
48
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
37
- // 内置团队引擎:随插件一起发布的品牌化产物在 lib/teams/ 下(由 scripts/brand.mjs 从
38
- // vendor/ 里的上游镜像改名生成),这里在 T专家 内部挂载它 ——
49
+ // 内置团队引擎:随插件一起发布的品牌化产物在 lib/teams/ 下(源自第三方引擎,改名后
50
+ // 由本项目直接维护),这里在 T专家 内部挂载它 ——
39
51
  // 安装 T专家 即自带团队引擎与团队面板,不再需要单独安装引擎插件。
40
52
  // 上游来源、版本与许可见 THIRD-PARTY-NOTICES 与 vendor/。
41
53
  import * as teamsEngine from "./teams/index.js";
@@ -60,8 +72,13 @@ export const inject = ["tools", "subagents", "systemPrompt", "settings", "comman
60
72
  export const Config = schema.object({
61
73
  root: schema.string().default(join(homedir(), ".t-team", "experts")),
62
74
  zhRoot: schema.string().default(join(homedir(), ".t-team", "zh")),
75
+ /**
76
+ * 用户自建专家的根目录(与内置名册分开:内置名册随包发布、升级时整棵替换,自建专家放进去会被覆盖)。
77
+ * 面板「新建专家」写到这里,固定落在 custom 分区。
78
+ */
79
+ customRoot: schema.string().default(join(homedir(), ".t-team", "custom")),
63
80
  provider: schema.string().default("spawn"),
64
- /** 留空 = 自动扫描 root 下所有含 .md 的分区目录(上游新增分区不必改配置/代码)。 */
81
+ /** 留空 = 自动扫描 root 下所有含 .md 的分类目录(新增分类不必改配置/代码)。 */
65
82
  divisions: schema.array(schema.string()).default([]),
66
83
  maxDepth: schema.natural(),
67
84
  /** 一次 summon_t_experts 最多召唤几位专家。 */
@@ -132,6 +149,12 @@ export function apply(ctx, config) {
132
149
  // ---- 花名册加载 ----
133
150
  let entries = [];
134
151
  let effectiveDivisions = config.divisions.length > 0 ? config.divisions.slice() : DEFAULT_DIVISIONS.slice();
152
+ /** 自建专家根下发现的分区(默认只有 custom);进指纹,用于新建/删除后自动重载。 */
153
+ let customDivisions = [];
154
+ /** 只来自内置根的分区(官方、只读):自建分区不许与它们重名。 */
155
+ let rosterDivisions = [];
156
+ /** 自建分类的显示名表(键 = 声明过的自建分类,允许空分类)。 */
157
+ let customLabels = {};
135
158
  let divisionLabels = {};
136
159
  let loaded = false;
137
160
  let loading = null;
@@ -142,7 +165,7 @@ export function apply(ctx, config) {
142
165
  * 名册指纹:source.json、各分区目录、以及中文侧车文件的 mtime。
143
166
  *
144
167
  * 要点:运行期读的中文正文路径是 `zh/<分区>/<slug>.md`(不是 `manual-bodies/`,那是
145
- * 数据层的**输入**,由同步脚本合并后写进 `zh/<分区>/`)。所以 `zhRoot` 与每个
168
+ * 数据层的输入层,已冻结)。所以 `zhRoot` 与每个
146
169
  * `zh/<分区>/` 目录都必须进指纹 —— 否则「给某位专家**新增**一篇中文正文」这条最常见的
147
170
  * 补译操作不会触发重载:该专家的 personaPathZh 一直是 undefined,召唤时只能读英文。
148
171
  * (已在 verify 里用临时数据目录做过行为验证。)
@@ -161,15 +184,28 @@ export function apply(ctx, config) {
161
184
  ...effectiveDivisions.map((division) => mtimeOf(join(config.root, division))),
162
185
  ...effectiveDivisions.map((division) => mtimeOf(join(config.zhRoot, division))),
163
186
  ...effectiveDivisions.map((division) => mtimeOf(join(config.zhRoot, "manual-bodies", division))),
187
+ // 自建专家根:目录本身(新增/删除分区目录时变)+ 各分区目录(增删改单个专家时变)。
188
+ // 少了后一项,新建一位专家后指纹不变,面板要等到别的原因触发重载才看得到。
189
+ mtimeOf(config.customRoot),
190
+ ...customDivisions.map((division) => mtimeOf(join(config.customRoot, division))),
191
+ // 自建分区的显示名表:改一个已有文件不会变动父目录的 mtime,必须单独进指纹,
192
+ // 否则手工编辑 divisions.json 之后分区名不会刷新(与「新增 zh 正文不重载」同一类坑)。
193
+ mtimeOf(config.customRoot === undefined || config.customRoot === "" ? "" : join(config.customRoot, "divisions.json")),
164
194
  ]);
165
195
  return parts.join("|");
166
196
  }
167
197
 
168
198
  async function load() {
169
199
  try {
170
- const catalog = await loadCatalog(config.root, config.divisions, { zhRoot: config.zhRoot });
200
+ const catalog = await loadCatalog(config.root, config.divisions, {
201
+ zhRoot: config.zhRoot,
202
+ customRoot: config.customRoot,
203
+ });
171
204
  entries = [...catalog.values()];
172
205
  const discovered = catalogDivisions(catalog);
206
+ if (catalog.customDivisions !== undefined) customDivisions = catalog.customDivisions;
207
+ if (catalog.rosterDivisions !== undefined) rosterDivisions = catalog.rosterDivisions;
208
+ customLabels = catalog.customLabels ?? {};
173
209
  if (discovered.length > 0) effectiveDivisions = discovered;
174
210
  divisionLabels = catalog.labels ?? {};
175
211
  loadError = null;
@@ -207,22 +243,47 @@ export function apply(ctx, config) {
207
243
  async function snapshot() {
208
244
  await ensureReady();
209
245
  const enabled = enabledSet();
210
- return {
246
+ const experts = await Promise.all(usable().map(async (expert) => ({
211
247
  // 面板按 locale 取字段:name/description 为中文(缺译回退英文),nameEn/descriptionEn 恒为英文。
212
- experts: usable().map((expert) => ({
213
- slug: expert.slug,
214
- name: expert.nameZh ?? expert.name,
215
- nameEn: expert.nameEn,
216
- description: expert.descriptionZh ?? expert.description,
217
- descriptionEn: expert.descriptionEn,
218
- emoji: expert.emoji,
219
- division: expert.division,
220
- divisionZh: divisionOf(expert.division, divisionLabels).zh,
221
- divisionEn: divisionOf(expert.division, divisionLabels).en,
222
- conflict: expert.conflict === true,
223
- })),
248
+ slug: expert.slug,
249
+ name: expert.nameZh ?? expert.name,
250
+ nameEn: expert.nameEn,
251
+ description: expert.descriptionZh ?? expert.description,
252
+ descriptionEn: expert.descriptionEn,
253
+ emoji: expert.emoji,
254
+ division: expert.division,
255
+ divisionZh: divisionOf(expert.division, divisionLabels).zh,
256
+ divisionEn: divisionOf(expert.division, divisionLabels).en,
257
+ conflict: expert.conflict === true,
258
+ custom: expert.custom === true,
259
+ // 自建专家的内容指纹:编辑/删除时回传,拦住并发覆盖(内置专家不读文件、恒为空)。
260
+ hash: expert.custom === true ? await fileFingerprint(expert.personaPath) : "",
261
+ })));
262
+ return {
263
+ experts,
224
264
  enabled: [...enabled],
225
265
  revision: revision(),
266
+ // 自建专家固定落在这个分区;连同中文标签一起给面板,省得两端各写一份常量。
267
+ customDivision: CUSTOM_DIVISION,
268
+ customDivisionLabel: divisionOf(CUSTOM_DIVISION, divisionLabels).zh,
269
+ // 全部分类(面板的「分类」下拉与管理页):
270
+ // official=官方分类(只读镜像的一部分,名字跟随中文侧车)
271
+ // count=该分类下的专家总数,customCount=其中自建的
272
+ // 自建专家可以归到官方分类名下 —— 分类是**归属**,文件始终写 customRoot,官方镜像不受影响。
273
+ // 默认分类恒在(它就是新建专家的落点),其余官方在前、自建在后。
274
+ categories: [...new Set([CUSTOM_DIVISION, ...rosterDivisions, ...customDivisions, ...Object.keys(customLabels)])]
275
+ .filter((key) => key !== "")
276
+ .map((key) => {
277
+ const inCategory = entries.filter((expert) => expert.division === key);
278
+ return {
279
+ key,
280
+ label: divisionOf(key, divisionLabels).zh,
281
+ official: rosterDivisions.includes(key),
282
+ count: inCategory.length,
283
+ customCount: inCategory.filter((expert) => expert.custom === true).length,
284
+ };
285
+ })
286
+ .sort((a, b) => Number(b.official) - Number(a.official) || a.label.localeCompare(b.label, "zh")),
226
287
  };
227
288
  }
228
289
 
@@ -234,6 +295,222 @@ export function apply(ctx, config) {
234
295
  return { prompt: await readPersona(expert) };
235
296
  }
236
297
 
298
+ // ---- 自建专家(面板「新建 / 编辑 / 删除」)----
299
+ // 写的是 customRoot(默认 ~/.t-team/custom),**不碰内置名册** —— 内置根是破坏性镜像,
300
+ // 往里写自建专家会在下次同步时被当成"意外文件"删掉。
301
+ /** 带业务码的错误:remote 的 businessError 会原样透传 code 给面板。 */
302
+ function customError(codeKey, params = {}) {
303
+ const error = new Error(t(locale(), codeKey, params));
304
+ error.code = `tTeam/${codeKey.replace(/^error\./, "").replace(/[A-Z]/g, (ch) => `-${ch.toLowerCase()}`)}`;
305
+ return error;
306
+ }
307
+
308
+ /** 校验并归一化自建专家表单;重名会连累内置专家一起不可召唤,所以挡在写盘前。 */
309
+ function normalizeCustomInput(input, editingSlug) {
310
+ const name = String(input?.name ?? "").trim();
311
+ if (name === "") throw customError("error.customNameEmpty");
312
+ if (name.length > CUSTOM_LIMITS.name) {
313
+ throw customError("error.customTextTooLong", { field: "名称", limit: CUSTOM_LIMITS.name });
314
+ }
315
+ const description = String(input?.description ?? "").trim();
316
+ if (description === "") throw customError("error.customDescriptionEmpty");
317
+ if (description.length > CUSTOM_LIMITS.description) {
318
+ throw customError("error.customTextTooLong", { field: "简介", limit: CUSTOM_LIMITS.description });
319
+ }
320
+ const body = String(input?.body ?? "").trim();
321
+ if (body === "") throw customError("error.customBodyEmpty");
322
+ if (body.length > CUSTOM_LIMITS.body) {
323
+ throw customError("error.customTextTooLong", { field: "人格正文", limit: CUSTOM_LIMITS.body });
324
+ }
325
+ const nameEn = String(input?.nameEn ?? "").trim();
326
+ if (nameEn.length > CUSTOM_LIMITS.nameEn) {
327
+ throw customError("error.customTextTooLong", { field: "英文名", limit: CUSTOM_LIMITS.nameEn });
328
+ }
329
+ const descriptionEn = String(input?.descriptionEn ?? "").trim();
330
+ const emoji = String(input?.emoji ?? "").trim();
331
+
332
+ // 重名(中文名或英文名撞车):markConflicts 会把两位都标成冲突、双双不可召唤,
333
+ // 所以这里直接拒绝,并告诉用户撞上了谁。
334
+ for (const candidate of [name, nameEn]) {
335
+ if (candidate === "") continue;
336
+ const key = normalizedKey(candidate);
337
+ const clash = entries.find((expert) => expert.slug !== editingSlug
338
+ && (normalizedKey(expert.nameZh ?? expert.name) === key || normalizedKey(expert.nameEn) === key));
339
+ if (clash !== undefined) {
340
+ throw customError("error.customNameTaken", { name: candidate, other: clash.nameZh ?? clash.name });
341
+ }
342
+ }
343
+ return { name, nameEn, description, descriptionEn, emoji, body };
344
+ }
345
+
346
+ /** 写完立刻重载(不靠 mtime 分辨率:同一毫秒内的连续写也要看到最新结果)。 */
347
+ async function refreshCatalog() {
348
+ await load();
349
+ if (loadError !== null) throw loadError;
350
+ }
351
+
352
+ function requireCustomRoot() {
353
+ if (typeof config.customRoot !== "string" || config.customRoot.trim() === "") {
354
+ throw customError("error.customRootMissing");
355
+ }
356
+ return config.customRoot;
357
+ }
358
+
359
+ /**
360
+ * 定下这位自建专家该归到哪个分类(分类 = 面板里的归属,不决定文件写在哪)。
361
+ *
362
+ * 规则(服务端说了算,不信任面板传什么):
363
+ * - 空 → 默认分类 `custom`;
364
+ * - 必须过 `isValidDivision`(与扫描口径同一个判断,避免写出「列表里有、扫不到」的目录);
365
+ * - **可以**用官方分类名(学术/金融/游戏…):分类是归属,文件始终写 customRoot,
366
+ * 官方镜像目录一个字节都不动,所以不会被同步删掉(2026-09-12 用户选定)。
367
+ *
368
+ * @param keep - 当前所在分类:编辑时保留原值可以跳过校验(历史遗留的非 ASCII 目录名仍能就地保存)。
369
+ */
370
+ async function resolveDivision(raw, customRoot, keep) {
371
+ const requested = String(raw ?? "").trim();
372
+ const key = requested === "" ? (typeof keep === "string" && keep !== "" ? keep : CUSTOM_DIVISION) : requested;
373
+ if (typeof keep === "string" && key === keep) return key;
374
+ if (!isValidDivision(key)) throw customError("error.customDivisionInvalid", { division: key });
375
+ return key;
376
+ }
377
+
378
+ /** 分类管理只允许动自建分类:官方分类随包发布(名字与存亡都跟包内名册走,本机改不了)。 */
379
+ function requireCustomCategory(key) {
380
+ const name = String(key ?? "").trim();
381
+ if (!isValidDivision(name)) throw customError("error.customDivisionInvalid", { division: name });
382
+ if (rosterDivisions.includes(name)) throw customError("error.categoryOfficial", { division: name });
383
+ return name;
384
+ }
385
+
386
+ /** 该分类下已有的自建专家(删除分类前的安全检查)。 */
387
+ function customExpertsIn(division) {
388
+ return entries.filter((expert) => expert.division === division && expert.custom === true);
389
+ }
390
+
391
+ /** 分类是否已存在(官方名、已声明、或目录里已有专家都算)。 */
392
+ function categoryExists(key) {
393
+ return rosterDivisions.includes(key) || customDivisions.includes(key) || Object.hasOwn(customLabels, key);
394
+ }
395
+
396
+ /** 新建自建分类(可以暂时是空分类:目录等第一位专家落进来时才创建)。 */
397
+ async function createCategory(key, label) {
398
+ await ensureReady();
399
+ const customRoot = requireCustomRoot();
400
+ const name = String(key ?? "").trim();
401
+ if (!isValidDivision(name)) throw customError("error.customDivisionInvalid", { division: name });
402
+ if (categoryExists(name)) throw customError("error.categoryExists", { division: name });
403
+ const text = String(label ?? "").trim();
404
+ if (text.length > CUSTOM_LIMITS.divisionLabel) {
405
+ throw customError("error.customTextTooLong", { field: "分类显示名", limit: CUSTOM_LIMITS.divisionLabel });
406
+ }
407
+ await saveCustomLabel(customRoot, name, text);
408
+ await refreshCatalog();
409
+ return await snapshot();
410
+ }
411
+
412
+ /** 改自建分类的显示名(清空=回退到目录名本身)。 */
413
+ async function updateCategory(key, label) {
414
+ await ensureReady();
415
+ const customRoot = requireCustomRoot();
416
+ const name = requireCustomCategory(key);
417
+ if (!categoryExists(name)) throw customError("error.categoryMissing", { division: name });
418
+ const text = String(label ?? "").trim();
419
+ if (text.length > CUSTOM_LIMITS.divisionLabel) {
420
+ throw customError("error.customTextTooLong", { field: "分类显示名", limit: CUSTOM_LIMITS.divisionLabel });
421
+ }
422
+ await saveCustomLabel(customRoot, name, text);
423
+ await refreshCatalog();
424
+ return await snapshot();
425
+ }
426
+
427
+ /** 删除自建分类:里面还有自建专家就拒绝(不静默连删),删完顺手收掉空目录。 */
428
+ async function deleteCategory(key) {
429
+ await ensureReady();
430
+ const customRoot = requireCustomRoot();
431
+ const name = requireCustomCategory(key);
432
+ if (!categoryExists(name)) throw customError("error.categoryMissing", { division: name });
433
+ const owned = customExpertsIn(name);
434
+ if (owned.length > 0) {
435
+ throw customError("error.categoryNotEmpty", { division: name, count: owned.length });
436
+ }
437
+ await saveCustomLabel(customRoot, name, "");
438
+ // 只收空目录:目录里若还有别的文件(用户自己放的),rmdir 会失败,忽略即可。
439
+ await rmdirIfEmpty(join(customRoot, name));
440
+ await refreshCatalog();
441
+ return await snapshot();
442
+ }
443
+
444
+ /** 记下自建分区的中文显示名(没传就什么都不做,不动已有标签)。 */
445
+ async function rememberDivisionLabel(customRoot, division, label) {
446
+ const text = String(label ?? "").trim();
447
+ if (text === "" || text.length > CUSTOM_LIMITS.divisionLabel) return;
448
+ await saveCustomLabel(customRoot, division, text);
449
+ }
450
+
451
+ /** 新建自建专家 → 返回刷新后的整份快照。 */
452
+ async function createExpert(input) {
453
+ await ensureReady();
454
+ const customRoot = requireCustomRoot();
455
+ const slug = String(input?.slug ?? "").trim().toLowerCase();
456
+ if (!isValidSlug(slug)) throw customError("error.customSlugInvalid", { slug });
457
+ if (entries.some((expert) => expert.slug === slug)) {
458
+ throw customError("error.customSlugTaken", { slug });
459
+ }
460
+ const fields = normalizeCustomInput(input);
461
+ const division = await resolveDivision(input?.division, customRoot);
462
+ await writeFileAtomic(customExpertPath(customRoot, slug, division), serializeCustomExpert(fields));
463
+ await rememberDivisionLabel(customRoot, division, input?.divisionLabel);
464
+ await refreshCatalog();
465
+ return await snapshot();
466
+ }
467
+
468
+ /** 编辑自建专家(不改 slug;改分区=移动文件)→ 返回刷新后的快照。 */
469
+ async function updateExpert(slug, input, expectedHash) {
470
+ await ensureReady();
471
+ const customRoot = requireCustomRoot();
472
+ const expert = entries.find((item) => item.slug === slug);
473
+ if (expert === undefined) throw customError("error.customMissing", { slug });
474
+ if (expert.custom !== true) throw customError("error.customNotCustom", { slug });
475
+ const path = expert.customPath ?? customExpertPath(customRoot, slug, expert.division);
476
+ const actual = await fileFingerprint(path);
477
+ if (typeof expectedHash === "string" && expectedHash !== "" && actual !== expectedHash) {
478
+ throw customError("error.customConflict");
479
+ }
480
+ const fields = normalizeCustomInput(input, slug);
481
+ const division = await resolveDivision(input?.division, customRoot, expert.division);
482
+ const target = customExpertPath(customRoot, slug, division);
483
+ await writeFileAtomic(target, serializeCustomExpert(fields));
484
+ // 换分区=移动:老文件必须删掉,否则同一个 slug 会以两份文件出现在两个分区里
485
+ // (扫描按 slug 去重,面板上表现为"改完分区又弹回原处")。
486
+ if (target !== path) await removeCustomExpert(customRoot, slug, expert.division);
487
+ await rememberDivisionLabel(customRoot, division, input?.divisionLabel);
488
+ await refreshCatalog();
489
+ return await snapshot();
490
+ }
491
+
492
+ /** 删除自建专家 → 返回刷新后的快照。 */
493
+ async function deleteExpert(slug, expectedHash) {
494
+ await ensureReady();
495
+ const customRoot = requireCustomRoot();
496
+ const expert = entries.find((item) => item.slug === slug);
497
+ if (expert === undefined) throw customError("error.customMissing", { slug });
498
+ if (expert.custom !== true) throw customError("error.customNotCustom", { slug });
499
+ const path = expert.customPath ?? customExpertPath(customRoot, slug, expert.division);
500
+ const actual = await fileFingerprint(path);
501
+ if (typeof expectedHash === "string" && expectedHash !== "" && actual !== expectedHash) {
502
+ throw customError("error.customConflict");
503
+ }
504
+ await removeCustomExpert(customRoot, slug, expert.division);
505
+ // 删除后把该专家从启用列表里摘掉,否则设置段会留下一个指向空气的 slug
506
+ const enabled = [...enabledSet()].filter((item) => item !== slug);
507
+ if (enabled.length !== enabledSet().size) {
508
+ await ctx.settings.mutate(SETTINGS_NAMESPACE, [{ op: "set", path: ["enabled"], value: enabled }], revision());
509
+ }
510
+ await refreshCatalog();
511
+ return await snapshot();
512
+ }
513
+
237
514
  /** 整体替换启用列表(远程服务用)。 */
238
515
  async function setEnabled(next, expectedRevision) {
239
516
  const list = (Array.isArray(next) ? next : []).filter((item) => typeof item === "string");
@@ -244,8 +521,20 @@ export function apply(ctx, config) {
244
521
  return { enabled: [...enabledSet()], revision: revision() };
245
522
  }
246
523
 
247
- const catalogService = { snapshot, prompt, setEnabled, root: config.root };
248
- ctx.reflect.provide(CATALOG_SERVICE, catalogService);
524
+ /** 删掉一个空目录;非空或不存在都当没事(只用于分类删除后的收尾)。 */
525
+ async function rmdirIfEmpty(path) {
526
+ try {
527
+ await rmdir(path);
528
+ } catch {
529
+ // ENOTEMPTY / ENOENT 都无所谓:里面还有别的东西就不动它。
530
+ }
531
+ }
532
+
533
+ const catalogService = {
534
+ snapshot, prompt, setEnabled, root: config.root,
535
+ createExpert, updateExpert, deleteExpert,
536
+ createCategory, updateCategory, deleteCategory,
537
+ }; ctx.reflect.provide(CATALOG_SERVICE, catalogService);
249
538
 
250
539
  // ---- 召唤 ----
251
540
  async function runExpert(query, task, exec) {
@@ -476,7 +765,7 @@ export function apply(ctx, config) {
476
765
  },
477
766
  }));
478
767
 
479
- // ---- 内置团队引擎(vendor 自上游并品牌化;小队数据来自 team-profiles.py 生成的 JSON)----
768
+ // ---- 内置团队引擎(源自第三方引擎、已品牌化并入;小队数据来自 team-profiles.py 生成的 JSON)----
480
769
  // 配置路径与引擎标量都是 Config 字段(可从 cordis.yml 覆盖):标量以 Config 为准,
481
770
  // 数据文件只提供生成物本身(profiles / memberMaxDepth)——小队目录是数据,不是可调参数。
482
771
  const engineConfigDir = dirname(config.root);
@@ -556,6 +845,10 @@ export function apply(ctx, config) {
556
845
  const squads = createSquadService({
557
846
  teamsFile,
558
847
  catalog: catalogService,
848
+ // 编译器优先用包内那份(随插件版本走);包内没有(例如被裁剪的安装)才回退数据目录。
849
+ generator: existsSync(join(SNAPSHOT_DIR, "team-profiles.py"))
850
+ ? join(SNAPSHOT_DIR, "team-profiles.py")
851
+ : undefined,
559
852
  });
560
853
  ctx.reflect.provide(SQUAD_SERVICE, squads);
561
854