dsh-plugin-t-expert 0.2.4 → 0.2.6
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.md +86 -180
- package/THIRD-PARTY-NOTICES +23 -17
- package/data/source.json +3 -88
- package/data/team-profiles.py +1 -1
- package/data/zh/COVERAGE.json +1 -1
- package/lib/catalog.js +206 -42
- package/lib/client.js +458 -10
- package/lib/i18n.js +37 -5
- package/lib/index.js +315 -22
- package/lib/remote.js +173 -1
- package/lib/squads.js +9 -2
- package/package.json +2 -4
- package/vendor/dsh-agent-teams/VENDOR.md +0 -53
package/lib/catalog.js
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
* 其中 game-development 之类的分区可以再嵌套一层子目录(递归扫描)。
|
|
6
6
|
* persona 正文在召唤时按需读取,不在目录列表阶段预加载。
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { mkdir, readdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, relative } from "node:path";
|
|
10
11
|
|
|
11
12
|
/** 内置的分区目录名(= divisions.json 的键)。 */
|
|
12
13
|
export const DEFAULT_DIVISIONS = [
|
|
@@ -50,10 +51,12 @@ export const DIVISION_LABEL = {
|
|
|
50
51
|
specialized: { zh: "专业", en: "Specialized" },
|
|
51
52
|
support: { zh: "支持", en: "Support" },
|
|
52
53
|
testing: { zh: "测试", en: "Testing" },
|
|
54
|
+
/** 用户自建专家的分区(customRoot 下的默认分区),面板里与官方 22 个分区并列但可一眼分辨。 */
|
|
55
|
+
custom: { zh: "自定义", en: "Custom" },
|
|
53
56
|
};
|
|
54
57
|
|
|
55
58
|
/**
|
|
56
|
-
*
|
|
59
|
+
* 取分类显示名:优先侧车目录 zh/divisions.json(数据驱动,新增分类只需补数据),
|
|
57
60
|
* 其次内置对照表,最后回退分区 key。
|
|
58
61
|
*/
|
|
59
62
|
export function divisionOf(division, labels) {
|
|
@@ -73,7 +76,7 @@ export async function loadDivisionLabels(zhRoot) {
|
|
|
73
76
|
|
|
74
77
|
/**
|
|
75
78
|
* 自动发现分区:root 下所有直接含 .md 的顶层目录。
|
|
76
|
-
*
|
|
79
|
+
* 这样新增的分类不必改代码就能被扫描到。
|
|
77
80
|
*/
|
|
78
81
|
export async function discoverDivisions(root) {
|
|
79
82
|
let entries;
|
|
@@ -114,10 +117,13 @@ function field(frontmatter, key) {
|
|
|
114
117
|
return match === null ? undefined : unquote(match[1]);
|
|
115
118
|
}
|
|
116
119
|
|
|
117
|
-
/** 解析 frontmatter 元数据(name/description/descriptionEn/emoji/color/vibe)。 */
|
|
120
|
+
/** 解析 frontmatter 元数据(name/nameEn/description/descriptionEn/emoji/color/vibe)。 */
|
|
118
121
|
export function parseMetadata(frontmatter) {
|
|
119
122
|
return {
|
|
120
123
|
name: field(frontmatter, "name"),
|
|
124
|
+
// 内置名册的英文名就是 name(英文原文),只有自建专家会单独写 nameEn;
|
|
125
|
+
// 不解析它的话,自建专家的英文名落盘却读不回来,重名校验与英文locale 都会失效。
|
|
126
|
+
nameEn: field(frontmatter, "nameEn"),
|
|
121
127
|
description: field(frontmatter, "description"),
|
|
122
128
|
descriptionEn: field(frontmatter, "descriptionEn"),
|
|
123
129
|
emoji: field(frontmatter, "emoji"),
|
|
@@ -169,61 +175,103 @@ export async function assertDirectory(root) {
|
|
|
169
175
|
|
|
170
176
|
/**
|
|
171
177
|
* 载入花名册。
|
|
172
|
-
*
|
|
178
|
+
*
|
|
179
|
+
* 两个根:`root` 是随包发布的内置名册(**只读**,由 add-expert.py 维护);`options.customRoot`
|
|
180
|
+
* 是用户自建专家的独立目录(默认 `~/.t-team/custom`)。两者必须分开——内置根是破坏性镜像,
|
|
181
|
+
* 放进去的自建专家会在下次同步时被当成"意外文件"删掉(2026-09-12 实测)。
|
|
182
|
+
*
|
|
183
|
+
* @param root 内置专家根目录
|
|
173
184
|
* @param divisions 要扫描的分区目录名
|
|
174
185
|
* @returns Map<slug, Expert>,Expert 额外带 personaPath(正文按需读取)
|
|
175
186
|
*/
|
|
176
187
|
export async function loadCatalog(root, divisions, options = {}) {
|
|
177
188
|
await assertDirectory(root);
|
|
178
189
|
const zhRoot = typeof options.zhRoot === "string" && options.zhRoot !== "" ? options.zhRoot : undefined;
|
|
190
|
+
const customRoot = typeof options.customRoot === "string" && options.customRoot !== "" ? options.customRoot : undefined;
|
|
179
191
|
// 中文侧车目录:names.json / descriptions.json / <与 experts 同相对路径的 .md>。
|
|
180
|
-
// 译文永不写进 experts
|
|
192
|
+
// 译文永不写进 experts(那是随包发布的只读名册),所以名册更新不受影响。
|
|
181
193
|
const zhNames = zhRoot === undefined ? {} : await readJson(join(zhRoot, "names.json"));
|
|
182
194
|
const zhDescriptions = zhRoot === undefined ? {} : await readJson(join(zhRoot, "descriptions.json"));
|
|
183
195
|
const divisionLabels = await loadDivisionLabels(zhRoot);
|
|
184
|
-
//
|
|
185
|
-
const
|
|
196
|
+
// 自建分类的显示名表(键 = 声明过的自建分类);官方标签优先级更高,见文件末尾的合并处。
|
|
197
|
+
const customLabels = await loadCustomLabels(customRoot);
|
|
198
|
+
// 未显式配置分类时自动发现:新增分类无需改代码。
|
|
199
|
+
// 发现结果一律按 isValidDivision 过一遍,**列表与扫描共用同一份过滤结果** ——
|
|
200
|
+
// 否则目录名不合规时会出现「分区名在列表里、专家却一个都没扫到」的空分区(已实测)。
|
|
201
|
+
const scanned = (divisions !== undefined && divisions.length > 0 ? divisions : await discoverDivisions(root))
|
|
202
|
+
.filter(isValidDivision);
|
|
186
203
|
const catalog = new Map();
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
|
|
205
|
+
/** 解析一个 persona 文件并放进名册;自定义根的文件额外带 custom 标记与写回路径。 */
|
|
206
|
+
async function ingest(fromRoot, division, filePath, fileName, isCustom) {
|
|
207
|
+
const slug = fileName.slice(0, -3);
|
|
208
|
+
if (!SLUG_PATTERN.test(slug)) return;
|
|
209
|
+
let raw;
|
|
210
|
+
try {
|
|
211
|
+
raw = stripBom(await readFile(filePath, "utf8"));
|
|
212
|
+
} catch {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
216
|
+
if (match === null) return;
|
|
217
|
+
const meta = parseMetadata(match[1]);
|
|
218
|
+
if (!meta.name || !meta.description) return;
|
|
219
|
+
if (catalog.has(slug)) {
|
|
220
|
+
if (isCustom) {
|
|
221
|
+
// 内置优先:自定义目录里出现同名 slug 时保留内置那位,不覆盖也不报冲突。
|
|
222
|
+
console.warn(`[t-team] 自定义专家与内置专家同 slug,已忽略:${slug}(${filePath})`);
|
|
197
223
|
return;
|
|
198
224
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
225
|
+
console.warn(`[t-team] slug 冲突,后者覆盖前者:${slug}`);
|
|
226
|
+
}
|
|
227
|
+
const entry = {
|
|
228
|
+
slug,
|
|
229
|
+
name: meta.name,
|
|
230
|
+
nameEn: meta.nameEn ?? meta.name,
|
|
231
|
+
description: meta.description,
|
|
232
|
+
descriptionEn: meta.descriptionEn ?? "",
|
|
233
|
+
emoji: meta.emoji ?? "",
|
|
234
|
+
color: meta.color ?? "",
|
|
235
|
+
vibe: meta.vibe ?? "",
|
|
236
|
+
division,
|
|
237
|
+
personaPath: filePath,
|
|
238
|
+
relativePath: relative(fromRoot, filePath),
|
|
239
|
+
};
|
|
240
|
+
if (isCustom) {
|
|
241
|
+
// 自建专家的名字就是用户写的那一个(通常是中文),不需要侧车译文即可显示中文。
|
|
242
|
+
entry.nameZh = meta.name;
|
|
243
|
+
entry.custom = true;
|
|
244
|
+
entry.customPath = filePath;
|
|
245
|
+
}
|
|
246
|
+
catalog.set(slug, entry);
|
|
220
247
|
}
|
|
248
|
+
|
|
249
|
+
for (const division of scanned) {
|
|
250
|
+
await walkMarkdown(join(root, division), (filePath, fileName) => ingest(root, division, filePath, fileName, false));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// 自建专家根:目录可以不存在(还没建过任何自建专家),分区同样自动发现。
|
|
254
|
+
let customDivisions = [];
|
|
255
|
+
if (customRoot !== undefined) {
|
|
256
|
+
customDivisions = (await discoverDivisions(customRoot)).filter(isValidDivision);
|
|
257
|
+
for (const division of customDivisions) {
|
|
258
|
+
await walkMarkdown(join(customRoot, division), (filePath, fileName) => ingest(customRoot, division, filePath, fileName, true));
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
221
262
|
if (catalog.size === 0) throw new Error(`专家目录为空或没有可解析的专家:${root}`);
|
|
222
263
|
markConflicts(catalog);
|
|
223
|
-
catalog.divisions = scanned;
|
|
224
|
-
catalog.
|
|
264
|
+
catalog.divisions = [...scanned, ...customDivisions.filter((item) => !scanned.includes(item))];
|
|
265
|
+
catalog.rosterDivisions = scanned; // 只来自内置根(官方分类,只读)
|
|
266
|
+
catalog.customDivisions = customDivisions;
|
|
267
|
+
catalog.customRoot = customRoot;
|
|
268
|
+
// 自建分类的显示名表;它的**键**同时就是"声明过的自建分类"——允许空分类(目录里还没有专家)。
|
|
269
|
+
catalog.customLabels = customLabels;
|
|
270
|
+
// 官方标签优先:官方分类的显示名跟随中文侧车,不允许被本机覆盖(2026-09-12 用户选定)。
|
|
271
|
+
catalog.labels = { ...customLabels, ...divisionLabels };
|
|
225
272
|
if (zhRoot !== undefined) {
|
|
226
273
|
for (const expert of catalog.values()) {
|
|
274
|
+
if (expert.custom === true) continue; // 自建专家不查侧车
|
|
227
275
|
const nameZh = zhNames[expert.slug];
|
|
228
276
|
if (typeof nameZh === "string" && nameZh !== "") expert.nameZh = nameZh;
|
|
229
277
|
const descriptionZh = zhDescriptions[expert.slug];
|
|
@@ -296,6 +344,122 @@ function normalizeName(value) {
|
|
|
296
344
|
return String(value ?? "").trim().toLowerCase();
|
|
297
345
|
}
|
|
298
346
|
|
|
347
|
+
/** 导出给写回服务做「重名」校验用(与 resolveExpert 同一口径)。 */
|
|
348
|
+
export function normalizedKey(value) {
|
|
349
|
+
return normalizeName(value);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------- 自建专家(写回)
|
|
353
|
+
|
|
354
|
+
/** 自建专家的默认分区目录名(新建时的落点;用户可以在面板里另建分区)。 */
|
|
355
|
+
export const CUSTOM_DIVISION = "custom";
|
|
356
|
+
/** 自建专家的字段长度上限(校验用,写回前拦,不给面板塞坏数据)。 */
|
|
357
|
+
export const CUSTOM_LIMITS = { name: 60, nameEn: 80, description: 240, body: 40000, divisionLabel: 40 };
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* 分区目录名是否合法 —— **与扫描口径完全一致**(`SEGMENT_PATTERN` + 不许 `..`)。
|
|
361
|
+
*
|
|
362
|
+
* 两处必须共用这一个判断:`discoverDivisions()` 发现什么、`loadCatalog()` 就扫什么。
|
|
363
|
+
* 曾经列表用未过滤的结果、扫描用过滤过的结果,于是手建一个中文目录名后,
|
|
364
|
+
* 面板上凭空多出一个零专家的分区(2026-09-12 实测)。非 ASCII 分区名一律不支持:
|
|
365
|
+
* 分区 key 会进 `@` 源 id 与文件路径,ASCII 才和 slug、小队 key 的口径一致;
|
|
366
|
+
* 中文显示名走 `customDivisions[].label`(落在 `<customRoot>/divisions.json`)。
|
|
367
|
+
*/
|
|
368
|
+
export function isValidDivision(division) {
|
|
369
|
+
const key = String(division ?? "").trim();
|
|
370
|
+
return SEGMENT_PATTERN.test(key) && !key.includes("..");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** 自建专家的文件路径:`<customRoot>/<division>/<slug>.md`(division 省缺时落默认分区)。 */
|
|
374
|
+
export function customExpertPath(customRoot, slug, division = CUSTOM_DIVISION) {
|
|
375
|
+
return join(customRoot, division, `${slug}.md`);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 自建分区的显示名表:`{ "<division>": "<显示名>" }`(缺文件当空表)。 */
|
|
379
|
+
export async function loadCustomLabels(customRoot) {
|
|
380
|
+
if (typeof customRoot !== "string" || customRoot === "") return {};
|
|
381
|
+
const raw = await readJson(join(customRoot, "divisions.json"));
|
|
382
|
+
const out = {};
|
|
383
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
384
|
+
if (typeof value === "string" && value.trim() !== "" && isValidDivision(key)) out[key] = value.trim();
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 记下一个自建分区的显示名(原子写;label 为空则删除该键)。
|
|
391
|
+
* 面板里「新建分区」填的中文名落在这里,`divisionOf()` 就能把它显示成中文。
|
|
392
|
+
*/
|
|
393
|
+
export async function saveCustomLabel(customRoot, division, label) {
|
|
394
|
+
if (!isValidDivision(division)) return false;
|
|
395
|
+
const path = join(customRoot, "divisions.json");
|
|
396
|
+
const current = await loadCustomLabels(customRoot);
|
|
397
|
+
const text = String(label ?? "").trim();
|
|
398
|
+
if (text === "") delete current[division];
|
|
399
|
+
else current[division] = text;
|
|
400
|
+
await writeFileAtomic(path, `${JSON.stringify(current, null, 2)}\n`);
|
|
401
|
+
return true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** slug 是否合法(与扫描口径一致)。 */
|
|
405
|
+
export function isValidSlug(slug) {
|
|
406
|
+
return SLUG_PATTERN.test(String(slug ?? ""));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* 写 frontmatter 标量。常规值裸写;以引号/空白/特殊符号开头结尾时加引号。
|
|
411
|
+
* 两端都有半角引号的极端情况把 `"` 换成全角 `”`——保住结构,且没人会把这种名字当标识符用。
|
|
412
|
+
*/
|
|
413
|
+
function yamlScalar(value) {
|
|
414
|
+
const text = String(value ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
415
|
+
if (text === "") return "";
|
|
416
|
+
if (!/^[\s"']|[\s"']$|^[#\-?:!,&*|>%@`[\]{}]/.test(text)) return text;
|
|
417
|
+
if (!text.includes('"')) return `"${text}"`;
|
|
418
|
+
if (!text.includes("'")) return `'${text}'`;
|
|
419
|
+
return `"${text.replace(/"/g, "”")}"`;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** 把自建专家序列化成文件文本(frontmatter + persona 正文)。 */
|
|
423
|
+
export function serializeCustomExpert(fields) {
|
|
424
|
+
const lines = ["---", `name: ${yamlScalar(fields.name)}`];
|
|
425
|
+
if (fields.nameEn) lines.push(`nameEn: ${yamlScalar(fields.nameEn)}`);
|
|
426
|
+
lines.push(`description: ${yamlScalar(fields.description)}`);
|
|
427
|
+
if (fields.descriptionEn) lines.push(`descriptionEn: ${yamlScalar(fields.descriptionEn)}`);
|
|
428
|
+
if (fields.emoji) lines.push(`emoji: ${yamlScalar(fields.emoji)}`);
|
|
429
|
+
lines.push("custom: true");
|
|
430
|
+
lines.push("---", "", String(fields.body ?? "").trim(), "");
|
|
431
|
+
return lines.join("\n");
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** 文件内容指纹(并发编辑用:两个窗口改同一位自建专家时,后写的会因指纹不符被拒)。 */
|
|
435
|
+
export async function fileFingerprint(path) {
|
|
436
|
+
try {
|
|
437
|
+
return createHash("sha256").update(await readFile(path)).digest("hex").slice(0, 12);
|
|
438
|
+
} catch {
|
|
439
|
+
return "";
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** 原子写入:先写临时文件再 rename,避免面板读到半截文件。 */
|
|
444
|
+
export async function writeFileAtomic(path, text) {
|
|
445
|
+
await mkdir(dirname(path), { recursive: true });
|
|
446
|
+
const temp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
447
|
+
await writeFile(temp, text, "utf8");
|
|
448
|
+
await rename(temp, path);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 删除自建专家文件;文件已不在时返回 false(幂等)。 */
|
|
452
|
+
export async function removeCustomExpert(customRoot, slug, division = CUSTOM_DIVISION) {
|
|
453
|
+
const path = customExpertPath(customRoot, slug, division);
|
|
454
|
+
try {
|
|
455
|
+
await unlink(path);
|
|
456
|
+
return true;
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error?.code === "ENOENT") return false;
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
299
463
|
/**
|
|
300
464
|
* 去掉 emoji / 标点后的比较形式:面板与 @ 芯片里会带 emoji 与全角空格,
|
|
301
465
|
* 模型也可能把「🗺️ 地理学家」原样传回来,所以比较前摘掉非文字字符。
|