dsh-plugin-t-expert 0.3.88 → 0.3.89

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.
@@ -1,367 +0,0 @@
1
- #!/usr/bin/env python3
2
- """把 T专家 的小队定义(teams.json)编译成内置团队引擎的 profiles 配置。
3
-
4
- 职责分工:T专家 管「人」(名册 + 中文 + 人格),内置引擎 管「队」(成员生命周期、任务 DAG、
5
- 调度、邮箱、面板)。本脚本只做**数据转换**:读 teams.json + experts/ + zh/,把每位专家的
6
- 中文名与人格正文写成 profile 成员的 executionPrompt,然后写进 `t-team.config.json`
7
- (引擎由 T专家 在 apply 时挂载并读这份配置)。
8
-
9
- 用法:
10
- python3 team-profiles.py # 生成并写入 t-team.config.json(+ teams.resolved.json)
11
- python3 team-profiles.py --dry-run # 只打印校验与**将要写入的内容**(预览 == 产物)
12
- python3 team-profiles.py --mode full # 成员人格用完整正文(默认 summary 摘录)
13
- python3 team-profiles.py --no-write # 同上,但不动文件
14
-
15
- 注意(两个已确认的坑):
16
- 1) **不要把 stateDir 写进 t-team.config.json**:状态目录的唯一真源是插件侧 Config.stateDir,
17
- 数据文件里再留一份就会出现「文件里一个值、实际生效另一个值」的双来源(审计 D-1)。
18
- 2) AgentTeams 的 profile key 只支持 ASCII(中文 key 不会生成命令别名),中文放 description/aliases。
19
- """
20
- from __future__ import annotations
21
-
22
- import argparse
23
- import copy
24
- import json
25
- import os
26
- import re
27
- import shutil
28
- import sys
29
- import unicodedata
30
- from datetime import datetime
31
-
32
- HOME = os.path.expanduser("~")
33
- # 默认值相对**脚本自身位置**解析:本脚本与数据(teams.json / experts / zh)同目录,
34
- # 所以插件包内快照被播种到任意数据目录后都能直接跑,不依赖某台机器的目录布局。
35
- DATA_DIR = os.path.dirname(os.path.abspath(__file__))
36
- DEFAULT_TEAMS = os.path.join(DATA_DIR, "teams.json")
37
- DEFAULT_EXPERTS = os.path.join(DATA_DIR, "experts")
38
- DEFAULT_ZH = os.path.join(DATA_DIR, "zh")
39
- # profile patch 只用于清理历史遗留的 agent-teams 配置块(现在引擎由插件内置挂载)。
40
- # 目标 profile 不同时用 --patch 或 T_TEAM_PATCH 覆盖;文件不存在时静默跳过。
41
- DEFAULT_PATCH = os.environ.get("T_TEAM_PATCH", os.path.join(HOME, ".dsh", "profiles", "desktop", "cordis.patch.yml"))
42
-
43
- MARKER_START = "# >>> t-team teams(由 team-profiles.py 生成,勿手改;改 teams.json 后重跑)"
44
- MARKER_END = "# <<< t-team teams"
45
-
46
- MAX_PROFILES = 48 # 引擎侧 lib/teams/profiles.js 已把 MAX_TEAM_PROFILES 调到 48(原默认 16)
47
- # 一个 profile 的成员数上限(经验值:队长上下文压力)。
48
- # 只是**默认**:插件侧 Config.maxMembers 经 --max-members 覆盖它,两边同一个真源;
49
- # 直接跑本脚本(不带该参数)时行为与从前完全一致。
50
- MAX_MEMBERS = 8
51
- MAX_SEED_TASKS = 32 # AgentTeams: MAX_PROFILE_TASKS
52
- PROFILE_KEY_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
53
- NAME_FIELD = re.compile(r"^name\s*:", re.M)
54
-
55
-
56
- def sanitize_key(value: str) -> str:
57
- """与 AgentTeams 的 state.js sanitizeKey 同规则(用于预检成员名撞键)。"""
58
- cleaned = unicodedata.normalize("NFC", str(value)).strip().lower()
59
- cleaned = re.sub(r"[^\w\u4e00-\u9fff]+", "-", cleaned, flags=re.UNICODE).strip("-")
60
- return cleaned or "k-empty"
61
-
62
-
63
- def read_persona(path: str) -> str:
64
- text = open(path, encoding="utf-8", errors="replace").read()
65
- if text.startswith("\ufeff"):
66
- text = text[1:]
67
- match = re.match(r"^---\r?\n.*?\r?\n---\r?\n?(.*)$", text, re.S)
68
- return (match.group(1) if match else text).strip()
69
-
70
-
71
- def summarize(body: str, budget: int) -> str:
72
- """按行截取到预算内,避免从句子中间切断。"""
73
- if len(body) <= budget:
74
- return body
75
- out, used = [], 0
76
- for line in body.splitlines():
77
- if used + len(line) + 1 > budget and out:
78
- break
79
- out.append(line)
80
- used += len(line) + 1
81
- text = "\n".join(out).rstrip()
82
- return text + f"\n\n(以上为该专家的人格摘录,来自 T专家 名册;完整正文可用 summon_t_expert 获取。)"
83
-
84
-
85
- def load_roster(experts_root: str, zh_root: str) -> dict[str, dict]:
86
- names_path = os.path.join(zh_root, "names.json")
87
- desc_path = os.path.join(zh_root, "descriptions.json")
88
- names = json.load(open(names_path, encoding="utf-8")) if os.path.isfile(names_path) else {}
89
- descs = json.load(open(desc_path, encoding="utf-8")) if os.path.isfile(desc_path) else {}
90
- roster: dict[str, dict] = {}
91
- for dirpath, _dirs, files in os.walk(experts_root):
92
- for name in files:
93
- if not name.endswith(".md"):
94
- continue
95
- slug = name[:-3]
96
- path = os.path.join(dirpath, name)
97
- head = open(path, encoding="utf-8", errors="replace").read(600)
98
- if not (head.startswith("---") and NAME_FIELD.search(head)):
99
- continue
100
- rel = os.path.relpath(path, experts_root)
101
- zh_path = os.path.join(zh_root, rel)
102
- roster[slug] = {
103
- "slug": slug,
104
- "path": zh_path if os.path.isfile(zh_path) else path, # 中文侧车优先
105
- "zhBody": os.path.isfile(zh_path),
106
- "division": rel.split(os.sep)[0],
107
- "nameZh": names.get(slug),
108
- "descriptionZh": descs.get(slug),
109
- }
110
- return roster
111
-
112
-
113
- def build_members(slugs: list[str], roster: dict[str, dict], mode: str, budget: int,
114
- errors: list[str], where: str, max_members: int = MAX_MEMBERS) -> list[dict]:
115
- members, used_names = [], {}
116
- if len(slugs) > max_members:
117
- errors.append(f"{where}: 成员 {len(slugs)} 位超过上限 {max_members}")
118
- for slug in slugs:
119
- expert = roster.get(slug)
120
- if expert is None:
121
- errors.append(f"{where}: 名册里没有专家 {slug}")
122
- continue
123
- display = expert["nameZh"] or slug
124
- key = sanitize_key(display)
125
- if key in used_names and used_names[key] != display:
126
- errors.append(f"{where}: 成员名「{display}」与「{used_names[key]}」会撞同一个键({key})")
127
- continue
128
- used_names[key] = display
129
- body = read_persona(expert["path"])
130
- if mode == "reference":
131
- prompt = (f'你是 T专家 名册里的「{display}」({slug})。开始工作前先调用\n'
132
- f'summon_t_expert("{display}", "<本次分配给你的任务>") 取得完整工作准则,再按其产出交付。')
133
- elif mode == "full":
134
- prompt = body
135
- else:
136
- prompt = summarize(body, budget)
137
- members.append({"name": display, "role": slug, "executionPrompt": prompt, "zh": expert["zhBody"]})
138
- return members
139
-
140
-
141
- def yaml_scalar(value: str) -> str:
142
- """极窄的 YAML 标量输出(本脚本只需写字符串):只在必要时加引号。"""
143
- text = str(value)
144
- needs_quote = (
145
- text == ""
146
- or text != text.strip()
147
- or text[0] in "-?:,[]{}#&*!|>'\"%@`"
148
- or ": " in text
149
- or " #" in text
150
- or "\n" in text
151
- )
152
- if needs_quote:
153
- escaped = text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
154
- return f'"{escaped}"'
155
- return text
156
-
157
-
158
- def emit_member(member: dict, indent: str) -> str:
159
- lines = [f"{indent}- name: {yaml_scalar(member['name'])}",
160
- f"{indent} role: {yaml_scalar(member['role'])}",
161
- f"{indent} executionPrompt: |"]
162
- for line in member["executionPrompt"].splitlines() or [""]:
163
- lines.append(f"{indent} {line}".rstrip())
164
- return "\n".join(lines)
165
-
166
-
167
- def build_engine_config(spec: dict, profiles: dict[str, dict]) -> dict:
168
- """构造**真正会写下去**的引擎配置(`t-team.config.json` 的那份对象)。
169
-
170
- 预览与落盘共用这一个构造点,两者不可能再漂移 —— 这是「预览必须等于产物」的实现方式。
171
-
172
- 注意:**不要**把 stateDir 写进这份 JSON。状态目录的唯一真源是插件侧 Config.stateDir
173
- (lib/index.js 用它挂载引擎、找团队状态、跑 t_team_plan_check)。这里再写一份,就会出现
174
- 「数据文件里一个值、实际生效另一个值」的双来源:改开 Config.stateDir 的部署两边失明且无报错
175
- (审计 D-1)。历史数据文件里的该字段已删除,且插件不再读它。
176
- """
177
- return {
178
- "memberProvider": (spec.get("engine") or {}).get("memberProvider", "spawn"),
179
- "profiles": {
180
- key: {
181
- "description": profile["description"],
182
- "taskPlanning": profile["taskPlanning"],
183
- "members": [
184
- {"name": m["name"], "role": m["role"], "executionPrompt": m["executionPrompt"]}
185
- for m in profile["members"]
186
- ],
187
- **({"tasks": profile["tasks"]} if profile.get("tasks") else {}),
188
- }
189
- for key, profile in profiles.items()
190
- },
191
- }
192
-
193
-
194
- def remove_patch_block(patch_path: str, dry_run: bool) -> bool:
195
- """历史版本把 profiles 写进 profile patch 的 agent-teams 行;现在引擎由 T专家 内置挂载,
196
- 这里把那个标记块移除(幂等)。"""
197
- if not os.path.isfile(patch_path):
198
- return False
199
- old = open(patch_path, encoding="utf-8").read()
200
- pattern = re.compile(re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END) + r"\n?", re.S)
201
- if not pattern.search(old):
202
- return False
203
- if dry_run:
204
- return True
205
- shutil.copy2(patch_path, f"{patch_path}.bak.{datetime.now():%Y%m%d-%H%M%S}")
206
- open(patch_path, "w", encoding="utf-8").write(pattern.sub("", old).rstrip("\n") + "\n")
207
- return True
208
-
209
-
210
- def read_engine_state_dir(engine_config_path: str) -> object:
211
- """读盘上那份产物里的 stateDir 死字段(只为让预览如实说明「它会被丢弃」;读不到就返回 None)。"""
212
- if not os.path.isfile(engine_config_path):
213
- return None
214
- try:
215
- return json.load(open(engine_config_path, encoding="utf-8")).get("stateDir")
216
- except (OSError, json.JSONDecodeError):
217
- return None
218
-
219
-
220
- def main() -> int:
221
- ap = argparse.ArgumentParser()
222
- ap.add_argument("--teams", default=DEFAULT_TEAMS)
223
- ap.add_argument("--experts", default=DEFAULT_EXPERTS)
224
- ap.add_argument("--zh", default=DEFAULT_ZH)
225
- ap.add_argument("--patch", default=DEFAULT_PATCH)
226
- ap.add_argument("--mode", choices=["summary", "full", "reference"], help="覆盖默认人格模式")
227
- ap.add_argument("--max-members", type=int, default=MAX_MEMBERS,
228
- help=f"一个 profile 的成员数上限(默认 {MAX_MEMBERS};插件侧由 Config.maxMembers 透传)")
229
- ap.add_argument("--dry-run", action="store_true")
230
- ap.add_argument("--no-write", action="store_true")
231
- args = ap.parse_args()
232
-
233
- if args.max_members < 1:
234
- print(f"错误:--max-members 必须是正整数,收到 {args.max_members}", file=sys.stderr)
235
- return 1
236
- if not os.path.isfile(args.teams):
237
- print(f"错误:找不到小队定义 {args.teams}", file=sys.stderr)
238
- return 1
239
- spec = json.load(open(args.teams, encoding="utf-8"))
240
- defaults = spec.get("defaults") or {}
241
- roster = load_roster(args.experts, args.zh)
242
- if not roster:
243
- print(f"错误:{args.experts} 里没有专家,先跑 sync.sh", file=sys.stderr)
244
- return 1
245
-
246
- errors: list[str] = []
247
- profiles: dict[str, dict] = {}
248
- aliases: dict[str, str] = {}
249
- raw_profiles = spec.get("profiles") or {}
250
- if len(raw_profiles) > MAX_PROFILES:
251
- errors.append(f"小队 {len(raw_profiles)} 支超过 AgentTeams 上限 {MAX_PROFILES}")
252
-
253
- disabled = [key for key, profile in raw_profiles.items() if profile.get("enabled") is False]
254
- for key in disabled:
255
- print(f" 跳过(已关停):{key}")
256
-
257
- for key, profile in raw_profiles.items():
258
- if profile.get("enabled") is False:
259
- continue
260
- if not PROFILE_KEY_RE.match(key):
261
- errors.append(f"小队 key「{key}」不合法:只能用 a-z0-9 与 -(AgentTeams 的 profile key 必须是 ASCII)")
262
- continue
263
- for alias in profile.get("aliases") or []:
264
- if alias in aliases:
265
- errors.append(f"别名「{alias}」被 {aliases[alias]} 与 {key} 同时使用")
266
- elif alias in raw_profiles and alias != key:
267
- errors.append(f"别名「{alias}」与另一支小队的 key 冲突")
268
- else:
269
- aliases[alias] = key
270
- mode = args.mode or profile.get("personaMode") or defaults.get("personaMode") or "summary"
271
- budget = int(profile.get("personaBudget") or defaults.get("personaBudget") or 1600)
272
- members = build_members(list(profile.get("members") or []), roster, mode, budget, errors, key, args.max_members)
273
- if not members:
274
- errors.append(f"{key}: 没有任何有效成员")
275
- continue
276
- tasks = profile.get("tasks") or []
277
- if len(tasks) > MAX_SEED_TASKS:
278
- errors.append(f"{key}: 固定任务 {len(tasks)} 条超过上限 {MAX_SEED_TASKS}")
279
- described = profile.get("description") or f"{key}"
280
- profiles[key] = {
281
- "description": described,
282
- "taskPlanning": profile.get("taskPlanning") or defaults.get("taskPlanning") or "captain",
283
- "members": members,
284
- "tasks": tasks,
285
- "aliases": list(profile.get("aliases") or []),
286
- "mode": mode,
287
- }
288
-
289
- print(f"名册专家 : {len(roster)}")
290
- print(f"小队 : {len(profiles)} / {MAX_PROFILES} 支")
291
- for key, profile in profiles.items():
292
- zh_count = sum(1 for m in profile["members"] if m["zh"])
293
- names = "、".join(m["name"] for m in profile["members"])
294
- line = (f" {key:20} {len(profile['members'])} 人 · {profile['taskPlanning']} · "
295
- f"{profile['mode']} · {profile['description']}")
296
- print(line)
297
- if profile["aliases"]:
298
- print(f" {'':18}别名: {'/'.join(profile['aliases'])}")
299
- print(f" {'':18}成员: {names}")
300
- print(f" {'':18}人格语言: 中文 {zh_count}/{len(profile['members'])}(缺中文的用英文原文)")
301
-
302
- if errors:
303
- print("\n校验失败:", file=sys.stderr)
304
- for error in errors:
305
- print(" ✗", error, file=sys.stderr)
306
- return 1
307
-
308
- # 引擎配置:T专家 在 apply 时读它并 ctx.plugin(内置引擎, 该配置)
309
- engine_config_path = os.path.join(os.path.dirname(os.path.abspath(args.teams)), "t-team.config.json")
310
- engine_config = build_engine_config(spec, profiles)
311
-
312
- if args.dry_run or args.no_write:
313
- # 预览必须等于产物:这里打印的就是上面那个 engine_config 对象。
314
- # 旧实现在这里打印一段带 `- id: agent-teams` / `stateDir: .agent-teams` 的**遗留 patch 块**,
315
- # 既与实际产物不符,又与 build_engine_config 的「不要把 stateDir 写进这份 JSON」注释自相矛盾
316
- # —— 预览是给人看的,看到的东西必须等于真正会写下去的东西(复核 F-1)。
317
- preview = copy.deepcopy(engine_config)
318
- legacy_state_dir = read_engine_state_dir(engine_config_path)
319
- # 预览的键集合必须与产物一致,所以 stateDir **不作为产物键**出现;
320
- # 盘上若还留着历史死字段,另起一行如实说明它会被丢弃(不塞进 JSON 里假装是产物)。
321
- print("\n" + "=" * 60)
322
- print(f"预览:将写入 {engine_config_path}(内容与该文件实际写入的完全一致):\n")
323
- print(json.dumps(preview, ensure_ascii=False, indent=2))
324
- print("\nstateDir:不是本文件的产物字段(预览与产物都不含它)。"
325
- "状态目录的唯一真源是插件侧 Config.stateDir(审计 D-1);"
326
- f"历史遗留字段{'会被丢弃(盘上当前值 ' + repr(legacy_state_dir) + ')' if legacy_state_dir is not None else '已不在盘上'}。")
327
- if args.dry_run:
328
- print("(dry-run,未写盘)")
329
- return 0
330
-
331
- with open(engine_config_path, "w", encoding="utf-8") as handle:
332
- json.dump(engine_config, handle, ensure_ascii=False, indent=2)
333
- handle.write("\n")
334
- print(f" 引擎配置 : {engine_config_path}")
335
-
336
- removed = remove_patch_block(args.patch, args.dry_run)
337
-
338
- # 同时写一份「已解析」的小队清单:/t 的列表用它显示中文成员名
339
- resolved_path = os.path.join(os.path.dirname(os.path.abspath(args.teams)), "teams.resolved.json")
340
- json.dump({
341
- "generatedAt": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
342
- "source": os.path.basename(args.teams),
343
- "profiles": {
344
- key: {
345
- "description": profile["description"],
346
- "aliases": profile["aliases"],
347
- "taskPlanning": profile["taskPlanning"],
348
- "mode": profile["mode"],
349
- "members": [{"name": m["name"], "role": m["role"]} for m in profile["members"]],
350
- }
351
- for key, profile in profiles.items()
352
- },
353
- }, open(resolved_path, "w", encoding="utf-8"), ensure_ascii=False, indent=2)
354
- open(resolved_path, "a", encoding="utf-8").write("\n")
355
- print(f" 解析清单 : {resolved_path}")
356
- total = sum(len(m["executionPrompt"]) for p in profiles.values() for m in p["members"])
357
- if removed:
358
- print(f"\n已从 {args.patch} 移除旧的 agent-teams 配置块(团队引擎现由 T专家 内置挂载)")
359
- print(f" 成员人格体积: {total} 字符({len(profiles)} 支小队)")
360
- print("\n下一步:重启 DSH Desktop 让新配置生效,然后就能用:")
361
- for key, profile in profiles.items():
362
- print(f" /t {profile['aliases'][0] if profile['aliases'] else key} <目标> 或 /t --profile {key} <目标>")
363
- return 0
364
-
365
-
366
- if __name__ == "__main__":
367
- raise SystemExit(main())