wezard 1.3.22 → 1.4.0

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.
Files changed (43) hide show
  1. package/CLAUDE.md +4 -2
  2. package/README.md +43 -37
  3. package/dist/daemon/chat-name.js +65 -2
  4. package/dist/daemon/chat-name.js.map +1 -1
  5. package/dist/daemon/detail.js +7 -0
  6. package/dist/daemon/detail.js.map +1 -1
  7. package/dist/daemon/graph.js +25 -3
  8. package/dist/daemon/graph.js.map +1 -1
  9. package/dist/daemon/inbound.js +156 -89
  10. package/dist/daemon/inbound.js.map +1 -1
  11. package/dist/daemon/index.js +773 -125
  12. package/dist/daemon/index.js.map +1 -1
  13. package/dist/daemon/jobs.js +91 -0
  14. package/dist/daemon/jobs.js.map +1 -0
  15. package/dist/daemon/mirror-bridge.js +194 -14
  16. package/dist/daemon/mirror-bridge.js.map +1 -1
  17. package/dist/daemon/mirror-store.js.map +1 -1
  18. package/dist/daemon/notices.js +70 -0
  19. package/dist/daemon/notices.js.map +1 -0
  20. package/dist/daemon/peers.js +93 -61
  21. package/dist/daemon/peers.js.map +1 -1
  22. package/dist/daemon/spawn-tmux.js +46 -4
  23. package/dist/daemon/spawn-tmux.js.map +1 -1
  24. package/dist/daemon/tasks.js +90 -0
  25. package/dist/daemon/tasks.js.map +1 -0
  26. package/dist/daemon/wizard.js +151 -0
  27. package/dist/daemon/wizard.js.map +1 -0
  28. package/dist/mcp/server.js +236 -102
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/shared/cli-backends.js +10 -0
  31. package/dist/shared/cli-backends.js.map +1 -1
  32. package/dist/shared/config.js +54 -14
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/detail-render.js +3 -2
  35. package/dist/shared/detail-render.js.map +1 -1
  36. package/dist/shared/detail-store.js.map +1 -1
  37. package/dist/shared/schedule-spec.js +240 -0
  38. package/dist/shared/schedule-spec.js.map +1 -0
  39. package/dist/shared/session-label.js +8 -4
  40. package/dist/shared/session-label.js.map +1 -1
  41. package/package.json +1 -1
  42. package/dist/daemon/topics.js +0 -106
  43. package/dist/daemon/topics.js.map +0 -1
@@ -3,6 +3,7 @@
3
3
  import { mkdirSync, writeFileSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { tailTurnsWithTools, keepalivePingSigs, renderPeerMentionHint } from "./peers.js";
6
+ import { noticeSuffixFor } from "./notices.js";
6
7
  import { expandHome, sanitizeId } from "../shared/paths.js";
7
8
  import { tryConsumeClaim, persistClaim, ackClaim, shouldAutoClaim, ackAutoClaim } from "./claim.js";
8
9
  import { getLastResponse } from "./last-response.js";
@@ -13,6 +14,7 @@ import { syncProjectConfig, renderSyncReport } from "./cfg-sync.js";
13
14
  import { captureQuota, renderQuotaReport } from "./quota.js";
14
15
  import { tagOfKey, baseOfKey, withTagHeader, parseTagHeader, tagTokenRe, allTags, allCompoundAddresses, labelFor } from "../shared/session-label.js";
15
16
  import { chatNameOf, chatBaseOf, clearChatName, listChatNames, peerAddress, setChatName } from "./chat-name.js";
17
+ import { wizardName, wizardStore } from "./wizard.js";
16
18
  /** 判定"引用内容是否已在目标会话上下文里"时回看的轮数 —— 引用的通常是最近几轮
17
19
  * 里的某条气泡,再往前用户多半是真想把老内容重新拎出来说事。 */
18
20
  const QUOTE_TAIL_TURNS = 12;
@@ -102,12 +104,35 @@ const resolveAuditMirror = (mirrors, tag, who, chatWho) => {
102
104
  }
103
105
  return mirrors.find((m) => m.target === who || m.target === chatWho);
104
106
  };
105
- // `/new` optionally names which CLI to launch (`/new codebuddy`). Bare `/new`
106
- // keeps whatever CLI the chat's current session runs — see newSession's inherit
107
- // rule — so naming one is only needed to *switch* backends.
108
- const NEW_RE = /^\/new(?:\s+(claude-internal|claude|codebuddy))?$/i;
109
- const isNewCommand = (text) => NEW_RE.test(text.trim());
110
- const cliOfNewCommand = (text) => NEW_RE.exec(text.trim())?.[1]?.toLowerCase();
107
+ // `/new [cli] [model] [prompt…]` — 三个位置参数都可选, 从前往后逐个认领: 认得出
108
+ // 的 CLI 名吃进后端, 认得出的模型别名吃进 `--model`, 剩下的整段就是新会话的第一
109
+ // 句话 (spawn 完再照常走 dispatch 注入)。Bare `/new` 沿用当前会话的 CLI 与该 CLI
110
+ // 自己的默认模型 —— 见 newSession 的继承规则, 只有要*换*后端时才需要写出来。
111
+ const NEW_RE = /^\/new(?:\s+([\s\S]+))?$/i;
112
+ const CLI_TOKEN_RE = /^(claude-internal|claude|codebuddy)$/i;
113
+ // `--model` 认的短别名与完整 slug。刻意收得窄: 认不出的一律当正文 —— 把
114
+ // `/new 看看 sonnet 贵不贵` 的第一个词吃成模型, 比多打一个词烦得多。
115
+ const MODEL_TOKEN_RE = /^(?:default|opus|opusplan|sonnet|haiku|(?:claude|gpt|gemini|deepseek)-[\w.-]+)(?:\[1m\])?$/i;
116
+ const parseNewCommand = (text) => {
117
+ const m = NEW_RE.exec(text.trim());
118
+ if (!m)
119
+ return undefined;
120
+ // 递归吃前缀: 每个槽位只认领一次, 第一个认不出的 token 起整段都是 prompt。
121
+ const claim = (rest, acc) => {
122
+ const head = /^(\S+)(?:\s+([\s\S]*))?$/.exec(rest);
123
+ if (!head)
124
+ return acc;
125
+ const [, tok = "", tail = ""] = head;
126
+ if (!acc.cli && CLI_TOKEN_RE.test(tok)) {
127
+ return claim(tail, { ...acc, cli: tok.toLowerCase() });
128
+ }
129
+ if (!acc.model && MODEL_TOKEN_RE.test(tok)) {
130
+ return claim(tail, { ...acc, model: tok.toLowerCase() });
131
+ }
132
+ return { ...acc, prompt: rest };
133
+ };
134
+ return claim((m[1] ?? "").trim(), { prompt: "" });
135
+ };
111
136
  // `/cfgsync` (alias `/sync`) — reconcile the project's per-CLI config trees.
112
137
  // Bare form is a dry run; `apply` is the only form that writes.
113
138
  const CFGSYNC_RE = /^\/(?:cfgsync|sync)(?:\s+(apply))?$/i;
@@ -130,47 +155,56 @@ const isKillCommand = (text) => text.trim() === "/kill";
130
155
  const isEnterCommand = (text) => text.trim() === "/n";
131
156
  const isRevealCommand = (text) => text.trim() === "/reveal";
132
157
  const isHelpCommand = (text) => /^\/(?:help|\?|h)$/i.test(text.trim());
133
- // Static command reference. Grouped: session control, usage/info, topic
134
- // broadcast (natural-language, zh+en). Anything not matching a command is a
158
+ // Static command reference. Grouped: session control, usage/info. Anything
159
+ // not matching a command is a
135
160
  // prompt forwarded to the bound Claude session.
136
161
  const renderHelp = () => [
137
162
  "*wezard 命令*",
138
163
  "",
139
164
  "▎会话",
140
- "`/new` 新开会话并绑定本聊天 (沿用当前会话的 CLI)",
165
+ "`/new` 让一个全新的 wizard 就位并绑定本聊天 (沿用当前的 CLI;不继承上下文)",
166
+ "`/new <模型>` 指定模型新开 (opus / sonnet / haiku / 完整 slug)",
167
+ "`/new <问题>` 新开并把这句话作为第一句发过去",
141
168
  "`/clear` 清空当前会话上下文 (有待切项目时自动升级为 /new)",
142
- "`/sessions` 列出 live 会话 · `/sessions <emoji|id>` 切换",
169
+ "`/sessions` 列出本机所有 live 会话 · `/sessions <emoji|id>` 把本聊天改接到某一个",
143
170
  "`/stop` 打断当前生成 (Esc)",
144
- "`/kill` 结束本会话并移除 tmux pane (下条消息自动新开)",
171
+ "`/kill` 结束本会话并移除 tmux pane (下条消息自动让新的 wizard 就位)",
145
172
  "`/n` 向 CLI 输入回车 (Enter)",
146
173
  "`/reveal` 把终端的 tmux 窗口切到本会话",
147
174
  "",
148
175
  "▎切换 CLI 后端",
149
176
  "`/new codebuddy` 用指定 CLI 新开 (claude / claude-internal / codebuddy)",
177
+ "位置参数可叠:`/new codebuddy opus #docs 先读一遍 README` = 后端 + 模型 + 标签 + 首句。",
150
178
  "不写则沿用本会话当前的 CLI;新开 `#tag` 会话则继承本聊天的 CLI。",
151
179
  "切换后 `/clear`、`/stop`、`--resume` 自愈都仍绑在该 CLI 上。",
152
180
  "",
153
- "▎多会话路由",
154
- "同一聊天可同时运行多个 Agent 会话:消息中任意位置带 `#tag`(如 `#docs 帮我改 README`)",
155
- "即路由到该标签会话;不带 tag = 默认会话。tagged 会话的回复以 `emoji #tag` 前缀标注。",
156
- "`/clear #tag`、`/pwd #tag`、`/stop #tag` 等命令同理按 tag 路由。",
157
- "`#tag` 与 CLI 名可同时写:`/new codebuddy #docs` = 用 codebuddy 开 docs 会话。",
181
+ "▎wizard(绑定聊天的会话)",
182
+ "一个聊天里可以同时住着多个 wizard —— 各有各的终端、工作区、名字和职责。",
183
+ "消息里任意位置带 `#tag`(如 `#docs 帮我改 README`)就是找那一个;不带 = 默认那个。",
184
+ "它们的回复以 `emoji #tag` 打头。`/clear #tag`、`/pwd #tag`、`/stop #tag` 同理按 tag 走。",
185
+ "`#tag` 与 CLI 名可以同写:`/new codebuddy #docs` = 用 codebuddy 让 docs 就位。",
186
+ "wizard 知道自己是谁(名字=聊天名,带 tag 的读作 `聊天名#tag`)、在哪个目录、群里还有谁,",
187
+ "也知道自己能给自己起名、写职责、记长期记忆、上下文满了自己交接重开。",
188
+ "",
189
+ "▎分身(clone)",
190
+ "对 AI 说「分个身去干 X」「开三个分身分头处理」即可。分身默认**继承它此刻的上下文** ——",
191
+ "先把公共材料读进来、再分身,材料只读一遍却进了 N 份上下文;要白纸一张就说明白。",
192
+ "分身有自己的 `#tag`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
158
193
  "",
159
194
  "▎跨聊天",
160
195
  "`/name <名字>` 给本聊天起名 · `/name` 查看 · `/name -` 取消",
161
- "`/chats` 列出所有已知聊天及其会话",
162
- "名字 1-32 位字母/数字/`_`/`-`,全机唯一。起了名字,别的聊天才叫得到这里:",
163
- "`daily#fix` = daily 聊天的 `#fix` · `daily#` = 它的默认会话 · `fix` = 本聊天优先。",
164
- "对 AI 说「让 daily#fix 看一眼」「在 daily 里开个 #ingest 跑这个目录」即可,",
165
- "它会调 `send_peer` / `new_claude_session` 跨群寻址、跨群建会话。",
166
- "未命名的聊天不可寻址、也不可被建入 —— 想被叫到,就在那个群里发一次 `/name`。",
196
+ "`/chats` 列出所有已知聊天及其 wizard",
197
+ "名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字。",
198
+ "`daily#fix` = daily 聊天的 `#fix` · `daily#` = 它的默认 wizard · `fix` = 本聊天优先。",
199
+ "对 AI 说「让 daily#fix 看一眼」「在 daily 里开个 #ingest 跑这个目录」即可。",
200
+ "未命名的聊天叫不到、也生不进去 —— 想被叫到,就在那个群里发一次 `/name`。",
167
201
  "",
168
- "▎多会话协作",
169
- "`/peers` 列出本聊天的所有会话及忙闲状态",
170
- "同一聊天内的会话互为 peer,可以互相观察和驱动。直接说人话即可:",
171
- "「看下 `#fix` 的进展,推动它直到结束」— AI 会读它的终端、注入指令、等它跑完。",
172
- "「让 `#fix` 和 `#review` 互相迭代到 review 说 LGTM」— AI 会建一个 loop graph,",
173
- "把多个带 tag 的会话(可各用不同 CLI / 模型)串成流水线并循环驱动。",
202
+ "▎协作",
203
+ "`/peers`(或 `/wizards`) 列出本聊天的 wizard:名字、职责、忙闲、谁是谁的分身",
204
+ "同一聊天里的 wizard 互相看得见也驱动得动,直接说人话:",
205
+ "「看下 `#fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
206
+ "「让 `#fix` 和 `#review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
207
+ "它们之间的关键往返(派活、分身出生、收尾、结论)会在群里留痕,中间的催促只进详情页。",
174
208
  "",
175
209
  "▎信息 (免授权)",
176
210
  "`/id` 查看会话/权限 id",
@@ -181,16 +215,12 @@ const renderHelp = () => [
181
215
  "`/cfgsync` 预演跨 CLI 项目配置同步 · `/cfgsync apply` 执行 (需授权)",
182
216
  "`/help` 本帮助",
183
217
  "",
184
- "▎事件订阅 / 广播",
185
- "已迁到 MCP:直接对 AI 说「订阅 xxx」「广播 xxx: …」「每天8点广播 xxx: …」,",
186
- "它会调 `subscribe_topic` / `broadcast_topic` / `schedule_broadcast` 等工具处理。",
187
- "",
188
218
  "▎引用 (quote)",
189
219
  "引用消息 + 新文字:被引用内容作为上下文前缀附在你的话前。",
190
220
  "纯引用不加字:把被引用内容当正文重发 —— 微信会去重相同文本,",
191
221
  "这是重新触发同一条命令 (如 `/usage`) 的唯一方式。",
192
222
  "",
193
- "其余文本直接转发给已绑定的 Agent 会话。",
223
+ "其余文本直接转发给这个聊天绑定的 wizard。",
194
224
  ].join("\n");
195
225
  // /session(s) [arg] — list live Claude sessions, or switch the mirror to one.
196
226
  // Bare "/sessions" (or "/session") lists; an arg (animal emoji, sessionId, or
@@ -221,10 +251,11 @@ const renderSessionsList = (sessions, currentSid) => {
221
251
  "> 切换:`/sessions <emoji 或 id>`,如 `/sessions 🐼`",
222
252
  ].join("\n");
223
253
  };
224
- // /peers — roster of the sessions living in THIS chat (default + every `#tag`).
225
- // Distinct from /sessions, which sweeps the whole host: peers are the ones an
226
- // agent here can actually collaborate with (shared chat = shared address space).
227
- const isPeersCommand = (text) => /^\/(?:peers?|agents?)$/iu.test(text.trim());
254
+ // /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个 `#tag`)。与 /sessions
255
+ // 的区别是范围: /sessions 扫的是整台机器上所有 agent 会话 (包括人在终端里自己开
256
+ // 的、与企微无关的), 这里列的是同一个聊天里互相叫得动的那些 —— 同聊天 = 同一个
257
+ // 地址空间。名字与职责来自 wizard 注册表, 没登记过的就只显示 tag。
258
+ const isPeersCommand = (text) => /^\/(?:peers?|agents?|wizards?)$/iu.test(text.trim());
228
259
  const uniq = (xs) => [...new Set(xs)];
229
260
  const dirOf = (p) => p.cwd.replace(/^.*\//, "") || p.cwd;
230
261
  const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
@@ -232,31 +263,49 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
232
263
  // repeated N times — hoist those into the header and annotate rows only where
233
264
  // they actually differ. Rows are blank-line separated so a wrapped summary can't
234
265
  // visually merge into the next peer.
235
- const renderPeers = (peers) => {
266
+ const renderPeers = (peers, chatName) => {
236
267
  if (peers.length === 0)
237
- return "[wezard] 本聊天还没有会话。发消息或 `/new` 建一个。";
268
+ return "[wezard] 本聊天还没有 wizard。发消息或 `/new` 让一个就位。";
269
+ const reg = wizardStore();
238
270
  const dirs = uniq(peers.map(dirOf));
239
271
  const clis = uniq(peers.map((p) => p.cli));
240
272
  const shared = [dirs.length === 1 ? dirs[0] : "", clis.length === 1 ? clis[0] : ""].filter(Boolean);
273
+ // 家谱只画直系: 谁是谁的分身。整棵树留给 AI 侧的 wizard_roster —— 群里一行放不下。
274
+ const parentTag = (target) => {
275
+ const p = reg?.get(target)?.parent;
276
+ return p ? tagOfKey(p) || "默认" : "";
277
+ };
241
278
  const rows = peers.flatMap((p) => {
242
- const name = p.tag ? `#${p.tag}` : "默认";
279
+ const rec = reg?.get(p.target);
280
+ const addr = p.tag ? `#${p.tag}` : "默认";
281
+ const name = wizardName(rec, chatName, p.target);
282
+ // 名字推导不出新信息时 (就是 tag 本身) 不重复印一遍。
283
+ const title = name && name !== p.tag && name !== chatName ? `${name} \`${addr}\`` : addr;
243
284
  const state = !p.paneAlive ? "⚫️ 已关闭" : p.busy ? "🔴 忙" : "🟢 空闲";
244
- const varies = [dirs.length > 1 ? dirOf(p) : "", clis.length > 1 ? p.cli : ""].filter(Boolean);
285
+ const from = parentTag(p.target);
286
+ const varies = [
287
+ dirs.length > 1 ? dirOf(p) : "",
288
+ clis.length > 1 ? p.cli : "",
289
+ from ? `分身自 #${from}` : "",
290
+ ].filter(Boolean);
245
291
  const me = p.self ? " ⬅️ 本会话" : "";
246
292
  return [
247
- `**${p.label} ${name}** ${state}${varies.length ? ` · ${varies.join(" · ")}` : ""}${me}`,
293
+ `**${p.label} ${title}** ${state}${varies.length ? ` · ${varies.join(" · ")}` : ""}${me}`,
294
+ ...(rec?.description ? [` _${clip(rec.description, 48)}_`] : []),
248
295
  ` ${clip(p.summary, 64)}`,
249
- "",
296
+ "", // 行间空行: 没有它, 折行的摘要会和下一个 wizard 糊在一起
250
297
  ];
251
298
  });
252
- const named = peers.find((p) => p.chat)?.chat ?? "";
299
+ const named = chatName || peers.find((p) => p.chat)?.chat || "";
253
300
  return [
254
- `[wezard] 本聊天${named ? ` \`${named}\`` : ""}的会话 · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
301
+ `[wezard] 本聊天${named ? ` \`${named}\`` : ""}的 wizard · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
255
302
  "",
256
303
  ...rows,
257
- "> 协作:直接说「看下 #fix 的进展并推动它」,AI 会读它的终端并注入指令",
258
- named ? "" : "> 起名:`/name <名字>` — 起了名字,别的聊天才能用 `名字#tag` 叫到这里的会话",
259
- ].filter((l) => l !== "").join("\n");
304
+ "> 协作:直接说「看下 #fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
305
+ "> 分身:说「分个身去干 X」— AI 会 clone 一个带着当前上下文的 wizard,干完再收掉",
306
+ // 空行只在这一处按需省略 —— 上面那些是有意的分隔, 不能被一把 filter 掉。
307
+ ...(named ? [] : ["> 起名:`/name <名字>` — 起了名字,别的聊天才叫得到这里的 wizard(`名字#tag`)"]),
308
+ ].join("\n");
260
309
  };
261
310
  // /chats — 跨聊天目录。命名的聊天可以被 `名字#tag` 精确寻址;没命名的只能靠
262
311
  // 「全局唯一 tag」碰运气,所以这里把「未命名」显式标出来当作行动号召。
@@ -280,32 +329,40 @@ const renderChats = (roster) => {
280
329
  const matchSession = (sessions, arg) => sessions.find((s) => s.label === arg) ??
281
330
  sessions.find((s) => s.sessionId === arg) ??
282
331
  (arg.length >= 6 ? sessions.find((s) => s.sessionId.startsWith(arg)) : undefined);
283
- // Strip any "@<botname>" mention (leading, mid-text, or trailing) so it doesn't
284
- // leak into claude's prompt. WeCom may place the mention anywhere depending on
285
- // where the user typed it.
286
- // Safety: if the text contains more than one "@", it's ambiguous (user likely
287
- // also @'d a file path like "@src/foo.ts"), so leave it untouched rather than
288
- // risk eating the path.
289
- const stripMentions = (text) => {
290
- const atCount = (text.match(/@/gu) ?? []).length;
291
- if (atCount !== 1)
292
- return text;
293
- return text.replace(/\s*@\S+\s*/u, " ").replace(/\s+/gu, " ").trim();
294
- };
332
+ // ── @mention 剥离 ──────────────────────────────────────────────────────
333
+ // 机器人的显示名由建它的人自己取, 消息体里没有任何字段告诉我们叫什么。所以:
334
+ // 一条消息里只认定 **一个** 机器人名, 认定后把全文里同名的 @ 全部剥掉 ——
335
+ // 用户一条消息里可能 @ 它好几次, 但不可能有两个不同的机器人。
336
+ //
337
+ // 候选 token = `@` 之后到下一个空格/行尾为止, 且不含 `/` `\`。机器人名不可能
338
+ // 带 slash, 所以 `@src/foo.ts` 这类"喂文件"的路径连候选都进不来 —— 路径优先
339
+ // 被识别为路径, 这是整条规则的第一顺位。裸文件名 (`@README.md`) 没有 slash,
340
+ // 靠扩展名形状再排一道。
341
+ const MENTION_RE = "(^|\\s)@NAME(?=\\s|$)";
342
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
343
+ const mentionRe = (name) => new RegExp(MENTION_RE.replace("NAME", escapeRe(name)), "giu");
344
+ // 扩展名形状的裸 token: `@README.md` 是文件, 不是人。
345
+ const looksLikeFile = (name) => /\.[A-Za-z0-9]{1,8}$/u.test(name);
346
+ /** 这条消息里的机器人名 —— 第一个不像文件的 @token。 */
347
+ const detectBotName = (text) => [...text.matchAll(new RegExp(MENTION_RE.replace("NAME", "([^\\s/\\\\]+)"), "gu"))]
348
+ .map((m) => m[2] ?? "")
349
+ .find((n) => n && !looksLikeFile(n));
350
+ /** 剥掉 `@name` 的每一次出现; 分隔符 (`$1`) 留着, 免得吃掉换行 —— 代价是行尾的
351
+ * @ 会留下一个孤立空格, 所以收尾再清一遍每行的尾随空白。 */
352
+ const stripName = (text, name) => text
353
+ .replace(mentionRe(name), "$1")
354
+ .replace(/[^\S\n]{2,}/g, " ")
355
+ .replace(/[^\S\n]+$/gmu, "")
356
+ .trim();
295
357
  // DMs can't @ a bot — any "@" the user types is content (e.g. "@src/foo.ts"),
296
- // so we only strip mentions in group chats.
358
+ // so name detection is group-only. 配置里写死的名字 (`wrc.botNames`) 则任何
359
+ // 场景都剥: 单聊里手打 "@wezard /usage" 同样不该漏进 prompt。
297
360
  const isGroup = (msg) => msg.chattype === "group" && !!msg.chatid;
298
- // Always kill "@wezard" (bot's own name) regardless of chat type / @-count:
299
- // a DM user typing "@wezard start …" would otherwise leak the mention into
300
- // Claude's prompt and get semantically parsed (e.g. spawning wrc). Word-tail
301
- // guard `(?![A-Za-z0-9_])` keeps identifiers like "@wezard-foo" intact
302
- // while allowing CJK / punctuation right after.
303
- // `weclaude` stays in the alternation: the bot's WeCom display name is chosen
304
- // by the user, not by us, so pre-rename bots are still literally "@weclaude".
305
- const stripBotName = (text) => text.replace(/[ \t]*@(?:wezard|weclaude)(?![A-Za-z0-9_])[ \t]*/giu, " ").replace(/[ \t]{2,}/g, " ").trim();
306
- const maybeStripMentions = (msg, text) => {
307
- const cleaned = stripBotName(text);
308
- return isGroup(msg) ? stripMentions(cleaned) : cleaned;
361
+ const BUILTIN_BOT_NAMES = ["wezard", "weclaude"];
362
+ const maybeStripMentions = (names, msg, text) => {
363
+ const configured = names.find((n) => mentionRe(n).test(text));
364
+ const name = configured ?? (isGroup(msg) ? detectBotName(text) : undefined);
365
+ return name ? stripName(text, name) : text.trim();
309
366
  };
310
367
  // Render the user's "引用" (quoted message) into a markdown blockquote so the
311
368
  // claude prompt carries the upstream context. WeCom delivers `quote` as a
@@ -398,7 +455,7 @@ const parseQuote = (q) => {
398
455
  // 消息 / 目标已 `/clear`),照旧渲染成 markdown 引用块。
399
456
  // 纯引用不打字时,沿用旧的"把引用内容提成正文"重触发路径 —— 但同样只在内容不
400
457
  // 在目标上下文里时才有意义,否则那只是一次对该会话的空 nudge。
401
- const composeInbound = (msg, rawBody, inContext) => {
458
+ const composeInbound = (msg, rawBody, inContext, stripAt) => {
402
459
  const { tag: typed, cleaned } = parseTag(rawBody);
403
460
  const q = parseQuote(msg.quote);
404
461
  const tag = typed || q?.tag || "";
@@ -413,7 +470,7 @@ const composeInbound = (msg, rawBody, inContext) => {
413
470
  }
414
471
  if (q && !consumed) {
415
472
  // 提成正文时也剥一次 @mention,让 "@wezard /usage" → "/usage" 命中命令路径。
416
- const p = parseTag(maybeStripMentions(msg, q.body).trim());
473
+ const p = parseTag(stripAt(msg, q.body).trim());
417
474
  return { text: p.cleaned, tag: p.tag || tag, promoted: true };
418
475
  }
419
476
  return { text: cleaned, tag, promoted: false };
@@ -466,6 +523,9 @@ const downloadToInbox = async (deps, url, aesKey, msgid, index) => {
466
523
  };
467
524
  export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
468
525
  const inboxDir = expandHome(cfg.wrc.mirror.inboxDir);
526
+ // 已知的机器人名只随配置变; 下面所有入站路径共用这一个剥离器。
527
+ const botNames = [...BUILTIN_BOT_NAMES, ...cfg.wrc.botNames.filter(Boolean)];
528
+ const stripAt = (msg, text) => maybeStripMentions(botNames, msg, text);
469
529
  // Render /pwd output. Mirror mode reads the live attachment + persisted
470
530
  // store via bridge.getCwd; headless mode has no per-chat cwd, so it just
471
531
  // shows cfg.wrc.cwd as the global default.
@@ -491,11 +551,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
491
551
  // status bar (e.g. `#docs` → window `docs`, not the principal slug).
492
552
  // On success there is NO reply: newSession already pushed the single
493
553
  // "created + cwd" bubble. Only failures produce user-facing text.
494
- const spawnSession = async (who, cli, silent) => {
554
+ const spawnSession = async (who, cli, silent, model) => {
495
555
  if (!("newSession" in bridge))
496
556
  return { err: "[wezard] /new only available in mirror mode" };
497
557
  const tag = tagOf(who);
498
- const r = await bridge.newSession(who, tag || who, cli, { silent });
558
+ const r = await bridge.newSession(who, tag || who, cli, { silent, model });
499
559
  return r.ok ? {} : { err: `[wezard] /new failed: ${r.reason ?? "unknown"}` };
500
560
  };
501
561
  // 同一 `#tag` 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
@@ -511,7 +571,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
511
571
  return next;
512
572
  };
513
573
  // 显式 /new:排队但仍强制重开(用户就是要换一个)。
514
- const autoSpawnAndAttach = (who, cli) => serializeSpawn(who, () => spawnSession(who, cli));
574
+ const autoSpawnAndAttach = (who, cli, model) => serializeSpawn(who, () => spawnSession(who, cli, false, model));
515
575
  // 隐式建会话(裸 `#tag` 第一条消息):轮到自己时若前一条已经把会话建好,直接
516
576
  // 复用,不再 respawn —— 否则先到的消息会被注入进一个刚被杀掉的 pane。
517
577
  const ensureSession = (who) => serializeSpawn(who, async () => "hasMirrorTarget" in bridge && bridge.hasMirrorTarget(who) ? {} : await spawnSession(who, undefined, true));
@@ -667,14 +727,19 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
667
727
  // Runs BEFORE the mirror-not-attached short-circuit so it works as the
668
728
  // very first message from a fresh user. When routed with a `#tag`, the
669
729
  // tag becomes both the mirror-store key and the tmux window name.
670
- if (isNewCommand(text)) {
671
- const { err } = await autoSpawnAndAttach(who, cliOfNewCommand(text));
672
- if (err)
730
+ const nu = parseNewCommand(text);
731
+ if (nu) {
732
+ const { err } = await autoSpawnAndAttach(who, nu.cli, nu.model);
733
+ if (err) {
673
734
  await replyText(frame, msg, who, err);
735
+ return { stop: true };
736
+ }
737
+ // 带正文的 `/new`: 这段就是新会话的第一句。走和「隐式建会话后 fall through」
738
+ // 完全相同的 dispatch 路径 —— freshSpawn 的冷时序、peer 提示都在那里。
739
+ if (nu.prompt)
740
+ await send(frame, msg, who, nu.prompt);
674
741
  return { stop: true };
675
742
  }
676
- // 事件订阅 / 广播 / 定时已全部迁移到 MCP 工具(subscribe_topic /
677
- // broadcast_topic / schedule_broadcast …),不再有 IM 文本命令。
678
743
  // Authorized `/stop` — Esc the live pane to interrupt whatever Claude is
679
744
  // currently doing. Mirror-mode only; bails cleanly when no attachment.
680
745
  if (isStopCommand(text)) {
@@ -792,7 +857,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
792
857
  }
793
858
  let body;
794
859
  try {
795
- body = renderPeers(await bridge.peers(who));
860
+ body = renderPeers(await bridge.peers(who), chatNameOf(cfg, who));
796
861
  }
797
862
  catch (e) {
798
863
  body = `[wezard] /peers failed: ${e.message}`;
@@ -949,8 +1014,10 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
949
1014
  const send = async (frame, msg, who, text, images = []) => {
950
1015
  // 斜杠命令按行解析,尾巴上多挂一段会让它不再被识别成命令 —— 只标注普通消息。
951
1016
  const hint = text.trimStart().startsWith("/") ? "" : renderPeerMentionHint(peerMentions(who, text));
1017
+ // 同一条边界上再挂一段: 这个 wizard 不在场时群里发生的成员变动 (见 notices.ts)。
1018
+ const notice = noticeSuffixFor(who, text);
952
1019
  try {
953
- await bridge.dispatch({ principal: who, text: text + hint, images, frame, streamId: msg.msgid });
1020
+ await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid });
954
1021
  }
955
1022
  catch (e) {
956
1023
  log.error({ err: e.message }, "bridge dispatch failed");
@@ -964,7 +1031,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
964
1031
  const msg = frame.body;
965
1032
  if (!msg)
966
1033
  return;
967
- const { text, tag, promoted } = composeInbound(msg, maybeStripMentions(msg, msg.text?.content ?? ""), quoteInContext);
1034
+ const { text, tag, promoted } = composeInbound(msg, stripAt(msg, msg.text?.content ?? ""), quoteInContext, stripAt);
968
1035
  const who = sessionKey(chatPrincipal(msg), tag);
969
1036
  log.info({ msgid: msg.msgid, len: text.length, tag, hasQuote: !!msg.quote, promoted }, "rx text");
970
1037
  const { stop } = await gate(frame, msg, text, who);
@@ -979,7 +1046,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
979
1046
  log.info({ msgid: msg.msgid, hasQuote: !!msg.quote }, "rx image");
980
1047
  // Images carry no text of their own — the quote (if any) is the only
981
1048
  // routing signal; without it they land on the chat's default session.
982
- const { text, tag } = composeInbound(msg, "", quoteInContext);
1049
+ const { text, tag } = composeInbound(msg, "", quoteInContext, stripAt);
983
1050
  const who = sessionKey(chatPrincipal(msg), tag);
984
1051
  const { stop } = await gate(frame, msg, "", who);
985
1052
  if (stop)
@@ -1009,7 +1076,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1009
1076
  .filter((it) => it.msgtype === "text")
1010
1077
  .map((it) => it.text?.content ?? "")
1011
1078
  .join("\n");
1012
- const { tag } = composeInbound(msg, maybeStripMentions(msg, rawText), quoteInContext);
1079
+ const { tag } = composeInbound(msg, stripAt(msg, rawText), quoteInContext, stripAt);
1013
1080
  const who = sessionKey(chatPrincipal(msg), tag);
1014
1081
  const { stop } = await gate(frame, msg, "", who);
1015
1082
  if (stop)
@@ -1019,7 +1086,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1019
1086
  let imgIdx = 0;
1020
1087
  for (const item of msg.mixed?.msg_item ?? []) {
1021
1088
  if (item.msgtype === "text" && item.text?.content) {
1022
- const t = maybeStripMentions(msg, item.text.content);
1089
+ const t = stripAt(msg, item.text.content);
1023
1090
  if (t)
1024
1091
  texts.push(t);
1025
1092
  }
@@ -1034,7 +1101,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
1034
1101
  // Re-compose on the per-item stripped text: drops the routing `#tag` (it was
1035
1102
  // consumed above; leaving it in would leak into Claude) and attaches the
1036
1103
  // quote only when it isn't already in the target's context.
1037
- await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext).text, images);
1104
+ await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt).text, images);
1038
1105
  });
1039
1106
  // template_card_event is handled in approval module; no listener here.
1040
1107
  };