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/README.md +33 -16
- 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/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# T专家(dsh-plugin-t-expert)
|
|
2
2
|
|
|
3
|
-
**T专家** 把一个 **22
|
|
3
|
+
**T专家** 把一个 **22 个分类 / 314 位专家**的名册(含全量中文译文)
|
|
4
4
|
做成一个独立的 DeepSeek Harness 插件,并在插件内部**自带一套多智能体团队引擎**(多成员小队、任务 DAG、调度、邮箱、活动面板)。
|
|
5
5
|
|
|
6
6
|
装上一个插件,你同时得到三样东西:
|
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
| 能力 | 说明 |
|
|
9
9
|
| --- | --- |
|
|
10
10
|
| 专家名册 | 314 位专家,含**中文侧车**(中文名 / 中文简介 / 中文人格正文,当前覆盖率 314/314) |
|
|
11
|
-
| `@` 召唤 | 在输入框打 `@`
|
|
11
|
+
| `@` 召唤 | 在输入框打 `@` 出现按分类分组的中文菜单,选中的专家作为原生引用 chip 进入本轮对话 |
|
|
12
12
|
| 模型工具 | `list_t_experts` / `summon_t_expert` / `summon_t_experts` / `t_team_plan_check` 四个工具 |
|
|
13
13
|
| 小队命令 | `/t` 列出小队;`/t <小队名\|别名> <目标>` 直接拉起一支多成员小队干活 |
|
|
14
|
-
| 设置页 | 设置 → T
|
|
14
|
+
| 设置页 | 设置 → T专家,**三个标签**:**专家**(启停 / 看提示词 / 新建自建专家)、**分类**(新建 / 改名 / 删除自建分类)、**队伍**(小队模板 + 运行中的团队) |
|
|
15
15
|
|
|
16
16
|
专家名册与团队引擎**解耦**:团队引擎挂载失败(例如宿主子代理契约不匹配)时,
|
|
17
17
|
专家名册、`@` 召唤、`/t` 列表仍然照常可用。
|
|
@@ -91,12 +91,12 @@ error: profile "desktop" is managed exclusively by the Electron application
|
|
|
91
91
|
|
|
92
92
|
```
|
|
93
93
|
~/.t-team/
|
|
94
|
-
├── experts/ #
|
|
94
|
+
├── experts/ # 专家名册:<分类>/<slug>.md(22 个分类、314 位)
|
|
95
95
|
├── zh/ # 中文侧车(译文只在这里,永不写进 experts/)
|
|
96
96
|
│ ├── names.json # slug → 中文名
|
|
97
97
|
│ ├── descriptions.json # slug → 中文简介
|
|
98
|
-
│ ├── divisions.json #
|
|
99
|
-
│ ├──
|
|
98
|
+
│ ├── divisions.json # 分类中文标签
|
|
99
|
+
│ ├── <分类>/<slug>.md # 中文人格正文(与 experts/ 同相对路径)
|
|
100
100
|
│ ├── manual.json # 人工补译:names / descriptions(优先于自动层)
|
|
101
101
|
│ ├── manual-bodies/ # 人工补译正文(最高优先,不会被覆盖)
|
|
102
102
|
│ └── COVERAGE.json # 中文覆盖率与缺口清单
|
|
@@ -117,10 +117,11 @@ error: profile "desktop" is managed exclusively by the Electron application
|
|
|
117
117
|
|
|
118
118
|
| 配置项 | 类型 | 默认值 | 含义 |
|
|
119
119
|
| --- | --- | --- | --- |
|
|
120
|
-
| `root` | string | `~/.t-team/experts` | 专家名册目录,读 `experts
|
|
121
|
-
| `zhRoot` | string | `~/.t-team/zh` | 中文侧车目录(名字 / 简介 / 正文 /
|
|
120
|
+
| `root` | string | `~/.t-team/experts` | 专家名册目录,读 `experts/<分类>/<slug>.md` |
|
|
121
|
+
| `zhRoot` | string | `~/.t-team/zh` | 中文侧车目录(名字 / 简介 / 正文 / 分类标签) |
|
|
122
|
+
| `customRoot` | string | `~/.t-team/custom` | 自建专家目录(面板「新建专家」写这里,默认落 `custom` 分类,可在表单里另建分类;与 `root` 分开是硬要求) |
|
|
122
123
|
| `provider` | string | `"spawn"` | 召唤专家时使用的子代理 provider |
|
|
123
|
-
| `divisions` | string[] | `[]` | 留空 = 自动扫描 `root` 下所有含 `.md`
|
|
124
|
+
| `divisions` | string[] | `[]` | 留空 = 自动扫描 `root` 下所有含 `.md` 的分类目录 |
|
|
124
125
|
| `maxDepth` | natural | 无默认(不传则不加限制) | 传给子代理的嵌套深度上限 |
|
|
125
126
|
| `maxSummonBatch` | natural | `8` | 一次 `summon_t_experts` 最多几位专家 |
|
|
126
127
|
| `summonConcurrency` | natural | `4` | `summon_t_experts` 的并发度 |
|
|
@@ -154,7 +155,7 @@ error: profile "desktop" is managed exclusively by the Electron application
|
|
|
154
155
|
|
|
155
156
|
| 工具 | 作用 |
|
|
156
157
|
| --- | --- |
|
|
157
|
-
| `list_t_experts` |
|
|
158
|
+
| `list_t_experts` | 列出**已启用**专家的分类与数量;传入分类名可列出该分类专家(用于挑出唯一的专家名) |
|
|
158
159
|
| `summon_t_expert` | 召唤 1 位专家:以该专家的完整人格跑一个子代理并返回结果(阻塞等待) |
|
|
159
160
|
| `summon_t_experts` | 并行召唤多位专家:最多 8 位、并发 4;部分失败时仍返回成功的结果 |
|
|
160
161
|
| `t_team_plan_check` | **只读** DAG 预检:把打算创建的一批团队任务先跑一遍引擎校验,一次报出全部问题、警示与建议执行顺序 |
|
|
@@ -208,6 +209,8 @@ error: profile "desktop" is managed exclusively by the Electron application
|
|
|
208
209
|
|
|
209
210
|
随包分发的专家名册、中文译文与内置团队引擎各自按 MIT 授权使用,
|
|
210
211
|
各自的来源、版本与署名要求写在 `THIRD-PARTY-NOTICES` 里,**完整许可文本随包放在 `vendor/` 下**。
|
|
212
|
+
名册与引擎**不再从上游同步**:`data/experts/` 与 `lib/teams/` 现在就是本仓的真源 / 手工源码,
|
|
213
|
+
而上游项目的署名义务不因停止同步而消失,故这些声明必须保留。
|
|
211
214
|
再分发本插件(含修改版)时,请一并保留 `LICENSE`、`THIRD-PARTY-NOTICES` 与 `vendor/` 下的许可文本。
|
|
212
215
|
|
|
213
216
|
---
|
|
@@ -216,24 +219,38 @@ error: profile "desktop" is managed exclusively by the Electron application
|
|
|
216
219
|
|
|
217
220
|
```bash
|
|
218
221
|
npm install # 安装依赖(peer 依赖由 DSH 宿主提供)
|
|
219
|
-
npm run build #
|
|
222
|
+
npm run build # 构建客户端产物
|
|
220
223
|
npm run verify # 自检套件(插件契约、工具、remote、客户端产物、播种与快照一致性)
|
|
221
224
|
npm run sync-data # 把运行时数据同步进包内 data/(默认源 ~/.t-team)
|
|
222
225
|
```
|
|
223
226
|
|
|
224
227
|
> ⚠️ 这些 script 的实现文件**不在本仓库里**:维护者把它们放在仓库同级的运维目录
|
|
225
|
-
> (`../
|
|
228
|
+
> (`../build-client.mjs`、`../verify.mjs`、`../sync-data.mjs`、`../add-expert.py`,由 `package.json`
|
|
226
229
|
> 的 `scripts` 以相对路径引用)。只 clone 本仓库是跑不了构建与自检的;要发布请使用完整的工作目录。
|
|
227
230
|
>
|
|
228
|
-
>
|
|
229
|
-
>
|
|
231
|
+
> 内置团队引擎(`lib/teams/`)是**手工维护的源码**:品牌化生成链与上游镜像已经移除(2026-09-12),
|
|
232
|
+
> 改引擎直接改 `lib/teams/*.js` 即可,不再有"改规则 → 重新生成"这一步。
|
|
230
233
|
|
|
231
234
|
---
|
|
232
235
|
|
|
233
236
|
## 九、已知限制
|
|
234
237
|
|
|
235
|
-
-
|
|
236
|
-
|
|
238
|
+
- **名册的增删(内置名册)**:`data/experts/` 就是真源(不再从上游拉取)。维护者用运维台
|
|
239
|
+
`bash ~/web/t-team/tz.sh 1` 新增:选分类(或新建分类)→ 给一个专家 `.md` 的路径 → 脚本把它
|
|
240
|
+
同时写进源码 `data/experts/<分类>/<slug>.md`(随包发布)与运行时 `~/.t-team/experts/<分类>/`(立即生效)。
|
|
241
|
+
删除走 `tz.sh 2`,统计 `tz.sh 3`,一致性校验 `tz.sh 5`。要求 `.md` 开头有 frontmatter
|
|
242
|
+
(`name` 与 `description` 必填,`emoji` 可选);没有 frontmatter 的文件可以在命令里用
|
|
243
|
+
`--name/--description` 补出来。分类名与插件扫描口径一致:`a-z 0-9 . -`,不以 `.` 开头。
|
|
244
|
+
- **自定义专家**:在 设置 → T专家 → 专家 里点「+ 新建专家」,填名称、简介与人格正文即可;自建的会在卡片上带「自建」标记,可以就地编辑或删除。
|
|
245
|
+
它们写在 `customRoot`(默认 `~/.t-team/custom/<分类>/<slug>.md`),**与随包发布的内置名册(`experts/`)分开**:内置名册是包的一部分,重装/升级会整棵替换,往里放文件会被覆盖掉。
|
|
246
|
+
- **自建分类**:表单里的「分类」下拉列出**全部分类**——官方 22 个(学术 / 金融 / 游戏 …)和你自建的都可以选;
|
|
247
|
+
选「+ 新建分类…」则填两个字段——**目录名**(小写 ASCII,成为 `~/.t-team/custom/<目录名>/`)与**显示名**(中文,记在 `<customRoot>/divisions.json`)。
|
|
248
|
+
设置页的 **分类** 标签页可以新建、改名、删除自建分类,并看到每个分类下有多少专家(含其中自建多少)。
|
|
249
|
+
- **把自建专家归到官方分类名下是允许的,也是安全的**:分类只是**归属**,文件永远写 `~/.t-team/custom/<分类>/<slug>.md`,
|
|
250
|
+
内置名册目录(`experts/`)一个字节都不会动,所以升级插件也不会把它删掉。官方分类本身只读——它的名字与存亡跟随包内名册,不接受本机改名或删除。
|
|
251
|
+
删除自建分类时若里面还有自建专家会被拒绝(先移走或删掉),不会静默连删。编辑时改分类=移动文件。
|
|
252
|
+
- 自建专家默认落在「自定义」分类,建好后记得启用;也能被 `/t` 小队挑成队员。
|
|
253
|
+
- 中文侧车(`zh/` 下的名字 / 简介 / 正文)的增删改都按 mtime 指纹自动重载,不需要重启;名册与自建专家同理。
|
|
237
254
|
- 包内快照只在数据目录**缺项**时播种。升级插件只更新包内快照,**不会覆盖你已有的数据目录内容**。
|
|
238
255
|
- 小队定义的改动需要重启 DSH 才对**建队**生效(见第五、六节)。
|
|
239
256
|
- 改插件代码后需要重新 `npm run build`(改了 `src/client.jsx` 才会重新生成客户端产物)并重装/重启。
|
package/THIRD-PARTY-NOTICES
CHANGED
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
| 上游仓库 | https://github.com/NanmiCoder/dsh-agent-teams |
|
|
13
13
|
| 版本 | `0.1.17-rc.1` |
|
|
14
14
|
| 许可 | MIT(完整文本随包:`vendor/dsh-agent-teams/LICENSE`)— Copyright (c) 2026 程序员阿江(Relakkes) |
|
|
15
|
-
| 位置 | `
|
|
15
|
+
| 位置 | `lib/teams/`(16 个文件,由上述上游派生的品牌化产物:命令 `/agent-teams` → `/t`、工具 `agent_teams_*` → `t_team_*`)。它的客户端产物内联在 `lib/client.js` 里 |
|
|
16
|
+
| 许可全文 | `vendor/dsh-agent-teams/LICENSE` |
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
**本插件对该上游做的改动**(都在 `lib/teams/` 里,可直接对照阅读):
|
|
19
|
+
一是品牌化改名(命令与工具名、包 id、事件与路由前缀);二是少量**本地缺陷修补与必要的行为改动**
|
|
20
|
+
(例如把 profile 上限从 16 抬到 48、修正命令视图 key、修正交接期与终态任务的校验、
|
|
21
|
+
放行 `.env.example` 一类模板文件等)。上游文本从未被修改过——我们只在自己的产物里改。
|
|
22
|
+
|
|
23
|
+
> 2026-09-12 起,上游的**逐字节原样镜像**(`vendor/dsh-agent-teams/upstream/`)与品牌化生成工具
|
|
24
|
+
> (`brand.mjs`)已从仓库移除:`lib/teams/` 转为**手工维护的源码**,不再在构建时从上游重新生成。
|
|
25
|
+
> 停止跟随上游不改变署名义务,故本节保留。
|
|
22
26
|
|
|
23
27
|
## 2. 专家名册与中文侧车
|
|
24
28
|
|
|
@@ -27,20 +31,22 @@
|
|
|
27
31
|
| 英文名册 | The Agency / AgentLand(`msitarzewski/agency-agents`)— MIT,Copyright (c) 2025 AgentLand Contributors。完整文本随包:`vendor/third-party-licenses/agency-agents.LICENSE` |
|
|
28
32
|
| 中文译文 | `jnMetaCode/agency-agents-zh`(中文翻译与本地化)— MIT,Copyright (c) 2025 Michael Sitarzewski(英文原版)、Copyright (c) 2026 jnMetaCode(中文翻译与本地化)。完整文本随包:`vendor/third-party-licenses/agency-agents-zh.LICENSE` |
|
|
29
33
|
| 分发形态 | 上述两部分内容随 `@michengai/dsh-agency-agents` 一同分发(该包自身的 TypeScript 源码与构建脚本为 Apache-2.0,**本插件不打包其任何代码**) |
|
|
30
|
-
|
|
|
31
|
-
|
|
|
34
|
+
| 位置 | `data/experts/`(22 个分类 / 314 位)、`data/zh/`(中文名字 / 简介 / 人格正文 / 分类标签) |
|
|
35
|
+
| 维护方式 | 上述内容随本插件分发(`data/` 是发布源,不再从上游重新镜像);`data/source.json` 是该名册的清单(专家数 / 分类 / 更新时间) |
|
|
36
|
+
|
|
37
|
+
名册的构成分两部分(总计 314 位):
|
|
32
38
|
|
|
33
|
-
|
|
39
|
+
- **279 位来自 `msitarzewski/agency-agents`**(上表英文名册一行)。
|
|
40
|
+
- **35 位来自 `@michengai/dsh-agency-agents` 的包内快照**(company / hr / legal / supply-chain
|
|
41
|
+
四个分类,以及若干中国本地化角色)。这些条目在另一上游仓库的任何历史与分支里都不存在,只随该包分发。
|
|
42
|
+
它们的英文人格与中文译文同样按上表两个 MIT 项目(AgentLand Contributors / jnMetaCode)的条款使用,
|
|
43
|
+
完整许可文本与上表共用。与已有角色职能重复的条目未并入。
|
|
34
44
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
四个分区,以及若干中国本地化角色)。这些条目在上游仓库的任何历史与分支里都不存在,只随该包分发;
|
|
38
|
-
其英文人格与中文译文同样按上表两个 MIT 项目(AgentLand Contributors / jnMetaCode)的条款使用,
|
|
39
|
-
完整许可文本与上表共用。补入范围与逐条判定依据记录在 `data/source.json` 的
|
|
40
|
-
`extraAdded` / `extraSkipped`(与上游角色职能重复的条目会被排除,不并入名册)。
|
|
45
|
+
> 两部分内容自 2026-09-12 起随本插件分发并作为**名册真源**维护,不再从上游重新拉取或镜像
|
|
46
|
+
> (原镜像 / 补入脚本与逐条判定记录已移除;需要追溯当时来源与判定,见仓库 git 历史)。
|
|
41
47
|
|
|
42
|
-
`data/zh/manual.json` 与 `data/zh/manual-bodies/`
|
|
43
|
-
|
|
48
|
+
`data/zh/manual.json` 与 `data/zh/manual-bodies/` 是**本项目的补充翻译**(用于补齐尚未覆盖的条目
|
|
49
|
+
或在译文不准确时覆盖),随本插件以 MIT 发布,同样保留在包内。
|
|
44
50
|
|
|
45
51
|
## 3. 分发要求
|
|
46
52
|
|
package/data/source.json
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
"target": "<home>/web/t-team/data/experts",
|
|
5
|
-
"syncedAt": "2026-09-12 14:47:18 +0800",
|
|
2
|
+
"updatedAt": "2026-09-12 15:56:06 +0800",
|
|
3
|
+
"expertFiles": 314,
|
|
6
4
|
"divisions": [
|
|
7
5
|
"academic",
|
|
8
6
|
"company",
|
|
@@ -27,88 +25,5 @@
|
|
|
27
25
|
"support",
|
|
28
26
|
"testing"
|
|
29
27
|
],
|
|
30
|
-
"
|
|
31
|
-
"extraSource": "<home>/.dsh/profiles/desktop/node_modules/@michengai/dsh-agency-agents/assets/agency-agents",
|
|
32
|
-
"extraSourceVersion": "0.1.41",
|
|
33
|
-
"extraAdded": 35,
|
|
34
|
-
"extraSkipped": [
|
|
35
|
-
{
|
|
36
|
-
"division": "company",
|
|
37
|
-
"slug": "chief-of-staff",
|
|
38
|
-
"duplicateOf": "specialized-chief-of-staff",
|
|
39
|
-
"verdict": "duplicate"
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
"division": "engineering",
|
|
43
|
-
"slug": "engineering-network-engineer-china",
|
|
44
|
-
"duplicateOf": "engineering-china-network-engineer",
|
|
45
|
-
"verdict": "duplicate"
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
"division": "engineering",
|
|
49
|
-
"slug": "engineering-security-engineer",
|
|
50
|
-
"duplicateOf": "security-appsec-engineer",
|
|
51
|
-
"verdict": "duplicate"
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
"division": "engineering",
|
|
55
|
-
"slug": "engineering-threat-detection-engineer",
|
|
56
|
-
"duplicateOf": "security-threat-detection-engineer",
|
|
57
|
-
"verdict": "duplicate"
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
"division": "finance",
|
|
61
|
-
"slug": "finance-financial-forecaster",
|
|
62
|
-
"duplicateOf": "finance-financial-analyst",
|
|
63
|
-
"verdict": "near-duplicate"
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
"division": "hr",
|
|
67
|
-
"slug": "hr-recruiter",
|
|
68
|
-
"duplicateOf": "specialized-recruitment-specialist",
|
|
69
|
-
"verdict": "duplicate"
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
"division": "marketing",
|
|
73
|
-
"slug": "marketing-bilibili-strategist",
|
|
74
|
-
"duplicateOf": "marketing-bilibili-content-strategist",
|
|
75
|
-
"verdict": "near-duplicate"
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
"division": "marketing",
|
|
79
|
-
"slug": "marketing-ecommerce-operator",
|
|
80
|
-
"duplicateOf": "marketing-china-ecommerce-operator",
|
|
81
|
-
"verdict": "duplicate"
|
|
82
|
-
},
|
|
83
|
-
{
|
|
84
|
-
"division": "marketing",
|
|
85
|
-
"slug": "marketing-wechat-operator",
|
|
86
|
-
"duplicateOf": "marketing-wechat-official-account",
|
|
87
|
-
"verdict": "duplicate"
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
"division": "marketing",
|
|
91
|
-
"slug": "marketing-xiaohongshu-operator",
|
|
92
|
-
"duplicateOf": "marketing-xiaohongshu-specialist",
|
|
93
|
-
"verdict": "duplicate"
|
|
94
|
-
},
|
|
95
|
-
{
|
|
96
|
-
"division": "specialized",
|
|
97
|
-
"slug": "prompt-engineer",
|
|
98
|
-
"duplicateOf": "engineering-prompt-engineer",
|
|
99
|
-
"verdict": "duplicate"
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
"division": "specialized",
|
|
103
|
-
"slug": "specialized-pricing-optimizer",
|
|
104
|
-
"duplicateOf": "specialized-pricing-analyst",
|
|
105
|
-
"verdict": "near-duplicate"
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
"division": "support",
|
|
109
|
-
"slug": "support-recruitment-specialist",
|
|
110
|
-
"duplicateOf": "specialized-recruitment-specialist",
|
|
111
|
-
"verdict": "duplicate"
|
|
112
|
-
}
|
|
113
|
-
]
|
|
28
|
+
"note": "本仓名册清单:data/experts 是真源,由 add-expert.py 维护(上游同步已移除)"
|
|
114
29
|
}
|
package/data/team-profiles.py
CHANGED
|
@@ -42,7 +42,7 @@ DEFAULT_PATCH = os.environ.get("T_TEAM_PATCH", os.path.join(HOME, ".dsh", "profi
|
|
|
42
42
|
MARKER_START = "# >>> t-team teams(由 team-profiles.py 生成,勿手改;改 teams.json 后重跑)"
|
|
43
43
|
MARKER_END = "# <<< t-team teams"
|
|
44
44
|
|
|
45
|
-
MAX_PROFILES = 48 #
|
|
45
|
+
MAX_PROFILES = 48 # 引擎侧 lib/teams/profiles.js 已把 MAX_TEAM_PROFILES 调到 48(原默认 16)
|
|
46
46
|
MAX_MEMBERS = 8 # 一个 profile 的成员数上限(经验值:队长上下文压力)
|
|
47
47
|
MAX_SEED_TASKS = 32 # AgentTeams: MAX_PROFILE_TASKS
|
|
48
48
|
PROFILE_KEY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
package/data/zh/COVERAGE.json
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"missingDescription": [],
|
|
15
15
|
"missingBody": []
|
|
16
16
|
},
|
|
17
|
-
"generatedFrom": "<home
|
|
17
|
+
"generatedFrom": "<home>/web/t-team/vendor/agency-agents",
|
|
18
18
|
"divisions": 22,
|
|
19
19
|
"unlabelledDivisions": []
|
|
20
20
|
}
|
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
|
* 模型也可能把「🗺️ 地理学家」原样传回来,所以比较前摘掉非文字字符。
|