wezard 1.4.2 → 2.0.1
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +17 -13
- package/README.md +130 -77
- package/cli/wezard.sh +0 -62
- package/config.example.jsonc +3 -9
- package/dist/cli/init.js +12 -21
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +11 -7
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-session.js +136 -0
- package/dist/daemon/cc-session.js.map +1 -0
- package/dist/daemon/chat-name.js +8 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +54 -13
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +5 -7
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +188 -254
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +459 -258
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +370 -170
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +74 -0
- package/dist/daemon/model-select.js.map +1 -0
- package/dist/daemon/peers.js +10 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/session-name.js +4 -27
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +61 -16
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-gate.js +97 -0
- package/dist/daemon/task-gate.js.map +1 -0
- package/dist/daemon/task-registry.js +132 -0
- package/dist/daemon/task-registry.js.map +1 -0
- package/dist/daemon/tasks.js +126 -54
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard-memory.js +52 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +107 -41
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +181 -209
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +187 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +25 -34
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +14 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -7
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +26 -9
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +12 -6
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +55 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +228 -0
- package/dist/shared/role-view.js.map +1 -0
- package/dist/shared/schedule-spec.js +92 -116
- package/dist/shared/schedule-spec.js.map +1 -1
- package/dist/shared/session-label.js +103 -72
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +3 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/task-file.js +120 -0
- package/dist/shared/task-file.js.map +1 -0
- package/dist/shared/trigger.js +232 -0
- package/dist/shared/trigger.js.map +1 -0
- package/dist/shared/world.js +17 -5
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +67 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +424 -212
- package/web/chat.js +953 -323
- package/commands/wrc.md +0 -7
- package/dist/daemon/cc-bridge.js +0 -183
- package/dist/daemon/cc-bridge.js.map +0 -1
- package/dist/daemon/sessions.js +0 -4
- package/dist/daemon/sessions.js.map +0 -1
package/dist/daemon/inbound.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// Inbound text router. Hands the message off to
|
|
2
|
-
// (mode=headless) or the mirror bridge (mode=mirror).
|
|
1
|
+
// Inbound text router. Hands the message off to the mirror bridge.
|
|
3
2
|
import { mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { tailTurnsWithTools, keepalivePingSigs, renderPeerMentionHint } from "./peers.js";
|
|
@@ -12,9 +11,9 @@ import { computeUsage, renderUsageReport } from "./usage.js";
|
|
|
12
11
|
import { computeAuditReport } from "./audit.js";
|
|
13
12
|
import { syncProjectConfig, renderSyncReport } from "./cfg-sync.js";
|
|
14
13
|
import { captureQuota, renderQuotaReport } from "./quota.js";
|
|
15
|
-
import { tagOfKey, baseOfKey, withTagHeader, parseTagHeader,
|
|
16
|
-
import { chatNameOf,
|
|
17
|
-
import {
|
|
14
|
+
import { tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader, nameTokenRe, allNames, normalizeTag, uniqueTag, displayName, labelFor, tagLink } from "../shared/session-label.js";
|
|
15
|
+
import { chatNameOf, clearChatName, peerAddress, setChatName } from "./chat-name.js";
|
|
16
|
+
import { reclaimChatName, wizardStore } from "./wizard.js";
|
|
18
17
|
import { truncate } from "../shared/std.js";
|
|
19
18
|
/** 判定"引用内容是否已在目标会话上下文里"时回看的轮数 —— 引用的通常是最近几轮
|
|
20
19
|
* 里的某条气泡,再往前用户多半是真想把老内容重新拎出来说事。 */
|
|
@@ -22,19 +21,19 @@ const QUOTE_TAIL_TURNS = 12;
|
|
|
22
21
|
// Chat-binding key: stable id for "this conversation thread". Used as
|
|
23
22
|
// session-map key, mirror target, defaultChat. NOT used for auth.
|
|
24
23
|
const chatPrincipal = (msg) => msg.chattype === "group" && msg.chatid ? `chat:${msg.chatid}` : `user:${msg.from.userid}`;
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const m =
|
|
37
|
-
if (!m)
|
|
24
|
+
// 寻址靠 wizard 的**全局名字** `.name` —— 它可能住在任何一个聊天里。
|
|
25
|
+
// Must be space-delimited or edge-of-string so paths / extensions like `a.ts`
|
|
26
|
+
// survive. 一条消息只认**一个**路由名字, 规则:
|
|
27
|
+
// • 第一个解析得出的已知名字 (名册里有, 或本聊天里有这个 slot 的老会话) 胜出;
|
|
28
|
+
// • 都不认识时, 只有**消息开头**的 `.x` 才算「叫一个新 wizard 出来」——
|
|
29
|
+
// 正文里随手写的 `.gitignore` 不该凭空变出一个 wizard;
|
|
30
|
+
// 选中的那个 token 从正文里摘掉, 其余 `.foo` 原样留着 (见 peerMentions)。
|
|
31
|
+
// Token 规则收在 session-label(nameTokenRe / allNames),路由与标注共用同一把尺子。
|
|
32
|
+
const NAME_G = new RegExp(nameTokenRe().source, "gu");
|
|
33
|
+
const parseTag = (text, known) => {
|
|
34
|
+
const ms = [...text.matchAll(NAME_G)];
|
|
35
|
+
const m = ms.find((x) => known(x[2] ?? "")) ?? (ms[0]?.index === 0 ? ms[0] : undefined);
|
|
36
|
+
if (!m || m.index === undefined)
|
|
38
37
|
return { tag: "", cleaned: text };
|
|
39
38
|
const tag = m[2] ?? "";
|
|
40
39
|
const before = text.slice(0, m.index);
|
|
@@ -43,8 +42,23 @@ const parseTag = (text) => {
|
|
|
43
42
|
const cleaned = (before + sep + after).replace(/[ \t]+/g, " ").trim();
|
|
44
43
|
return { tag, cleaned };
|
|
45
44
|
};
|
|
46
|
-
const sessionKey = (base, tag) => (tag ? `${base}#${tag}` : base);
|
|
47
45
|
const tagOf = tagOfKey;
|
|
46
|
+
/** 名字 → 投递目标。名册 (全局) > 本聊天同名 slot (改名前的老 `#tag` 会话) > 在本聊天
|
|
47
|
+
* 新开一个 slot。纯函数: 新 wizard 的名字由 spawn 时 settleName 按 slot 落定。 */
|
|
48
|
+
const resolveName = (base, name, live) => {
|
|
49
|
+
if (!name)
|
|
50
|
+
return base;
|
|
51
|
+
const rec = wizardStore()?.byName(name);
|
|
52
|
+
if (rec)
|
|
53
|
+
return rec.target;
|
|
54
|
+
const slot = normalizeTag(name) || "wizard";
|
|
55
|
+
const legacy = keyOf(base, slot);
|
|
56
|
+
if (live(legacy))
|
|
57
|
+
return legacy;
|
|
58
|
+
// 这个 slot 已被一个改了名的 (冷) wizard 占着 → 挪到 `slot-N`, 别唤醒别人。
|
|
59
|
+
const taken = new Set((wizardStore()?.all() ?? []).filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)));
|
|
60
|
+
return keyOf(base, uniqueTag(slot, taken));
|
|
61
|
+
};
|
|
48
62
|
// Auth principals: any-of test against allowFrom. Tiered — allowing a user
|
|
49
63
|
// grants them access in any chat; allowing a group grants every member of
|
|
50
64
|
// that group access. DMs collapse to just the sender.
|
|
@@ -56,8 +70,7 @@ const authPrincipals = (msg) => {
|
|
|
56
70
|
};
|
|
57
71
|
// "会话id" = chat-binding (session/mirror key); "权限id" = either the group
|
|
58
72
|
// OR the sender — allowFrom passes if any one of them is whitelisted.
|
|
59
|
-
// Also surfaces per-id
|
|
60
|
-
// so users can copy-paste straight into a terminal to bind a Claude session.
|
|
73
|
+
// Also surfaces per-id 授权状态。
|
|
61
74
|
const renderIds = (msg, cfg) => {
|
|
62
75
|
const allowed = new Set(cfg.wrc.allowFrom.map((e) => sanitizeId(e)));
|
|
63
76
|
const mark = (id) => allowed.has("all") || allowed.has(id) ? "✅ 已授权" : "❌ 未授权";
|
|
@@ -68,28 +81,27 @@ const renderIds = (msg, cfg) => {
|
|
|
68
81
|
`群: \`${chat}\` ${mark(chat)}`,
|
|
69
82
|
`发送者: \`${sender}\` ${mark(sender)}`,
|
|
70
83
|
`(allowFrom 任一通过即可)`,
|
|
71
|
-
`在已有 Agent 会话中绑定本群聊: \`/wezard:wrc chat:${msg.chatid}\``,
|
|
72
84
|
].join("\n");
|
|
73
85
|
}
|
|
74
|
-
return
|
|
75
|
-
`会话id: \`${sender}\` ${mark(sender)}`,
|
|
76
|
-
`在已有 Agent 会话中绑定本单聊: \`/wezard:wrc user:${msg.from.userid}\``,
|
|
77
|
-
].join("\n");
|
|
86
|
+
return `会话id: \`${sender}\` ${mark(sender)}`;
|
|
78
87
|
};
|
|
79
88
|
const isIdCommand = (text) => text.trim() === "/id";
|
|
80
89
|
const isPwdCommand = (text) => text.trim() === "/pwd";
|
|
81
90
|
const isCostCommand = (text) => text.trim() === "/cost";
|
|
82
|
-
// `/audit` or `/audit
|
|
83
|
-
//
|
|
84
|
-
// without
|
|
91
|
+
// `/audit` or `/audit <name>`. With a name, `/audit` re-routes to that wizard's
|
|
92
|
+
// mirror (global name → target; an old slot id still matches, newest-by-mtime);
|
|
93
|
+
// without one, falls back to the caller's own mirror binding.
|
|
85
94
|
const parseAuditCommand = (text) => {
|
|
86
95
|
const m = /^\/audit(?:\s+(.+))?$/u.exec(text.trim());
|
|
87
|
-
return m ? { tag: (m[1] ?? "").trim().replace(
|
|
96
|
+
return m ? { tag: (m[1] ?? "").trim().replace(/^[.#]/, "") } : undefined;
|
|
88
97
|
};
|
|
89
98
|
const resolveAuditMirror = (mirrors, tag, who, chatWho) => {
|
|
90
99
|
if (tag) {
|
|
91
|
-
const
|
|
92
|
-
const
|
|
100
|
+
const named = wizardStore()?.byName(tag)?.target;
|
|
101
|
+
const exact = named ? mirrors.find((m) => m.target === named) : undefined;
|
|
102
|
+
if (exact)
|
|
103
|
+
return exact;
|
|
104
|
+
const matches = mirrors.filter((m) => tagOfKey(m.target) === tag);
|
|
93
105
|
if (matches.length <= 1)
|
|
94
106
|
return matches[0];
|
|
95
107
|
return matches
|
|
@@ -141,8 +153,8 @@ const parseCfgSyncCommand = (text) => {
|
|
|
141
153
|
const m = CFGSYNC_RE.exec(text.trim());
|
|
142
154
|
return m ? { apply: Boolean(m[1]) } : undefined;
|
|
143
155
|
};
|
|
144
|
-
// `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带
|
|
145
|
-
// 路由过来都命名同一个聊天,
|
|
156
|
+
// `/name` 读, `/name x` 写, `/name -` 摘掉。名字是 chat 级的 —— 带不带 `.name`
|
|
157
|
+
// 路由过来都命名同一个聊天, 所以这里不看路由目标。
|
|
146
158
|
const NAME_RE_CMD = /^\/name(?:\s+(\S+))?$/i;
|
|
147
159
|
const parseNameCommand = (text) => {
|
|
148
160
|
const m = NAME_RE_CMD.exec(text.trim());
|
|
@@ -175,44 +187,42 @@ const renderHelp = () => [
|
|
|
175
187
|
"",
|
|
176
188
|
"▎切换 CLI 后端",
|
|
177
189
|
"`/new codebuddy` 用指定 CLI 新开 (claude / claude-internal / codebuddy)",
|
|
178
|
-
"
|
|
179
|
-
"不写则沿用本会话当前的 CLI
|
|
190
|
+
"位置参数可叠:`.docs /new codebuddy opus 先读一遍 README` = 名字 + 后端 + 模型 + 首句。",
|
|
191
|
+
"不写则沿用本会话当前的 CLI;新叫出来的 wizard 继承本聊天的 CLI。",
|
|
180
192
|
"切换后 `/clear`、`/stop`、`--resume` 自愈都仍绑在该 CLI 上。",
|
|
181
193
|
"",
|
|
182
|
-
"▎wizard
|
|
183
|
-
"
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"wizard
|
|
188
|
-
"
|
|
194
|
+
"▎wizard",
|
|
195
|
+
"每个 wizard 有一个**全局唯一**的名字,写作 `.name`;聊天的默认 wizard 名字就是聊天名。",
|
|
196
|
+
"消息里带 `.name`(如 `.docs 帮我改 README`)就是找那一个 —— 它可以住在任何一个聊天里,",
|
|
197
|
+
"回复会回到你说话的这个群;不带 = 本聊天默认那个。开头写一个还不存在的 `.x` = 在这里叫出一个新的 x。",
|
|
198
|
+
"它们的回复以 `emoji .name` 打头。`/clear .x`、`/pwd .x`、`/stop .x` 同理按名字走。",
|
|
199
|
+
"wizard 知道自己是谁、在哪个目录、还有谁在,也能给自己改名、写职责、记长期记忆、上下文满了自己交接重开。",
|
|
200
|
+
"wizard 之间默认**私聊**,只有需要你知道的才发进群。",
|
|
189
201
|
"",
|
|
190
202
|
"▎分身(clone)",
|
|
191
203
|
"对 AI 说「分个身去干 X」「开三个分身分头处理」即可。分身默认**继承它此刻的上下文** ——",
|
|
192
204
|
"先把公共材料读进来、再分身,材料只读一遍却进了 N 份上下文;要白纸一张就说明白。",
|
|
193
|
-
"分身有自己的
|
|
205
|
+
"分身有自己的 `.name`、自己的终端,也能再生分身。活干完对 AI 说「收掉它们」。",
|
|
194
206
|
"",
|
|
195
207
|
"▎跨聊天",
|
|
196
208
|
"`/name <名字>` 给本聊天起名 · `/name` 查看 · `/name -` 取消",
|
|
197
209
|
"`/chats` 列出所有已知聊天及其 wizard",
|
|
198
|
-
"名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard
|
|
199
|
-
"
|
|
200
|
-
"对 AI 说「让 daily#fix 看一眼」「在 daily 里开个 #ingest 跑这个目录」即可。",
|
|
201
|
-
"未命名的聊天叫不到、也生不进去 —— 想被叫到,就在那个群里发一次 `/name`。",
|
|
210
|
+
"名字 1-32 位字母/数字/`_`/`-`,全机唯一;它同时就是这里默认 wizard 的名字 (撞名会自动加后缀)。",
|
|
211
|
+
"对 AI 说「让 .fix 看一眼」「在 daily 里开个 .ingest 跑这个目录」即可。",
|
|
202
212
|
"",
|
|
203
213
|
"▎协作",
|
|
204
214
|
"`/peers`(或 `/wizards`) 列出本聊天的 wizard:名字、职责、忙闲、谁是谁的分身",
|
|
205
215
|
"同一聊天里的 wizard 互相看得见也驱动得动,直接说人话:",
|
|
206
|
-
"「看下
|
|
207
|
-
"「让
|
|
208
|
-
"
|
|
216
|
+
"「看下 `.fix` 的进展,推动它直到结束」— AI 会读它的终端、派活、等它跑完。",
|
|
217
|
+
"「让 `.fix` 和 `.review` 互相迭代到 review 说 LGTM」— AI 会把它们串成一条循环流水线。",
|
|
218
|
+
"它们之间的往来默认只进各自的 rolepage;需要你知道的 (工单开/收工、公开讨论、结论) 才发进群。",
|
|
209
219
|
"",
|
|
210
220
|
"▎信息 (免授权)",
|
|
211
221
|
"`/id` 查看会话/权限 id",
|
|
212
222
|
"`/pwd` 当前项目路径",
|
|
213
223
|
"`/usage` 真实订阅额度 %",
|
|
214
224
|
"`/cost` token/成本估算",
|
|
215
|
-
"`/audit` 本会话 token/成本明细 (含 subagent) · `/audit
|
|
225
|
+
"`/audit` 本会话 token/成本明细 (含 subagent) · `/audit <名字>` 指定 wizard",
|
|
216
226
|
"`/cfgsync` 预演跨 CLI 项目配置同步 · `/cfgsync apply` 执行 (需授权)",
|
|
217
227
|
"`/help` 本帮助",
|
|
218
228
|
"",
|
|
@@ -252,7 +262,7 @@ const renderSessionsList = (sessions, currentSid) => {
|
|
|
252
262
|
"> 切换:`/sessions <emoji 或 id>`,如 `/sessions 🐼`",
|
|
253
263
|
].join("\n");
|
|
254
264
|
};
|
|
255
|
-
// /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 +
|
|
265
|
+
// /peers — 住在**这个聊天**里的 wizard 名册 (默认那个 + 每个分身)。与 /sessions
|
|
256
266
|
// 的区别是范围: /sessions 扫的是整台机器上所有 agent 会话 (包括人在终端里自己开
|
|
257
267
|
// 的、与企微无关的), 这里列的是同一个聊天里互相叫得动的那些 —— 同聊天 = 同一个
|
|
258
268
|
// 地址空间。名字与职责来自 wizard 注册表, 没登记过的就只显示 tag。
|
|
@@ -271,22 +281,21 @@ const renderPeers = (peers, chatName) => {
|
|
|
271
281
|
const clis = uniq(peers.map((p) => p.cli));
|
|
272
282
|
const shared = [dirs.length === 1 ? dirs[0] : "", clis.length === 1 ? clis[0] : ""].filter(Boolean);
|
|
273
283
|
// 家谱只画直系: 谁是谁的分身。整棵树留给 AI 侧的 wizard_roster —— 群里一行放不下。
|
|
274
|
-
|
|
284
|
+
// 每个 tag 都点得开它的 chat 详情页。
|
|
285
|
+
const addrOf = (target) => tagLink(target, `.${displayName(target) || "?"}`);
|
|
286
|
+
const parentAddr = (target) => {
|
|
275
287
|
const p = reg?.get(target)?.parent;
|
|
276
|
-
return p ?
|
|
288
|
+
return p ? addrOf(p) : "";
|
|
277
289
|
};
|
|
278
290
|
const rows = peers.flatMap((p) => {
|
|
279
291
|
const rec = reg?.get(p.target);
|
|
280
|
-
const
|
|
281
|
-
const name = wizardName(rec, chatName, p.target);
|
|
282
|
-
// 名字推导不出新信息时 (就是 tag 本身) 不重复印一遍。
|
|
283
|
-
const title = name && name !== p.tag && name !== chatName ? `${name} \`${addr}\`` : addr;
|
|
292
|
+
const title = addrOf(p.target);
|
|
284
293
|
const state = !p.paneAlive ? "⚫️ 已关闭" : p.busy ? "🔴 忙" : "🟢 空闲";
|
|
285
|
-
const from =
|
|
294
|
+
const from = parentAddr(p.target);
|
|
286
295
|
const varies = [
|
|
287
296
|
dirs.length > 1 ? dirOf(p) : "",
|
|
288
297
|
clis.length > 1 ? p.cli : "",
|
|
289
|
-
from ? `分身自
|
|
298
|
+
from ? `分身自 ${from}` : "",
|
|
290
299
|
].filter(Boolean);
|
|
291
300
|
const me = p.self ? " ⬅️ 本会话" : "";
|
|
292
301
|
return [
|
|
@@ -301,19 +310,18 @@ const renderPeers = (peers, chatName) => {
|
|
|
301
310
|
`[wezard] 本聊天${named ? ` \`${named}\`` : ""}的 wizard · ${peers.length} 个${shared.length ? ` · ${shared.join(" · ")}` : ""}`,
|
|
302
311
|
"",
|
|
303
312
|
...rows,
|
|
304
|
-
"> 协作:直接说「看下
|
|
313
|
+
"> 协作:直接说「看下 .fix 的进展并推动它」,AI 会读它的终端、派活、等它跑完",
|
|
305
314
|
"> 分身:说「分个身去干 X」— AI 会 clone 一个带着当前上下文的 wizard,干完再收掉",
|
|
306
315
|
// 空行只在这一处按需省略 —— 上面那些是有意的分隔, 不能被一把 filter 掉。
|
|
307
|
-
...(named ? [] : ["> 起名:`/name <名字>` —
|
|
316
|
+
...(named ? [] : ["> 起名:`/name <名字>` — 聊天名就是这里默认 wizard 的名字"]),
|
|
308
317
|
].join("\n");
|
|
309
318
|
};
|
|
310
|
-
// /chats —
|
|
311
|
-
// 「全局唯一 tag」碰运气,所以这里把「未命名」显式标出来当作行动号召。
|
|
319
|
+
// /chats — 跨聊天目录: 每个聊天里住着哪些 wizard (`.name`, 全局可达)。
|
|
312
320
|
const renderChats = (roster) => {
|
|
313
321
|
if (roster.length === 0)
|
|
314
322
|
return "[wezard] 还没有任何聊天在跑会话。";
|
|
315
323
|
const rows = roster.flatMap((c) => {
|
|
316
|
-
const sessions = c.targets.map((t) => (
|
|
324
|
+
const sessions = c.targets.map((t) => tagLink(t, `.${displayName(t) || "?"}`)).join(" · ") || "(无)";
|
|
317
325
|
const head = c.name ? `\`${c.name}\`` : `_(未命名)_ \`${c.base}\``;
|
|
318
326
|
return [`**${head}**${c.self ? " ⬅️ 本聊天" : ""} · ${c.targets.length} 个会话`, ` ${sessions}`, ""];
|
|
319
327
|
});
|
|
@@ -321,7 +329,7 @@ const renderChats = (roster) => {
|
|
|
321
329
|
`[wezard] 已知的聊天 · ${roster.length} 个`,
|
|
322
330
|
"",
|
|
323
331
|
...rows,
|
|
324
|
-
">
|
|
332
|
+
"> 寻址:`.名字`(如 `.fix`),全局唯一,在任何群里都叫得到",
|
|
325
333
|
].join("\n");
|
|
326
334
|
};
|
|
327
335
|
// Match a switch arg against a scanned session: animal emoji label, full
|
|
@@ -428,49 +436,42 @@ const canonContains = (haystack, needle) => {
|
|
|
428
436
|
};
|
|
429
437
|
const isLastResponseQuote = (target, quoted) => canonContains(getLastResponse(target) ?? "", quoted);
|
|
430
438
|
// ── 引用即路由 ─────────────────────────────────────────────────────────
|
|
431
|
-
// 群里要跟
|
|
432
|
-
// 带 `emoji
|
|
433
|
-
// 行首
|
|
439
|
+
// 群里要跟 `.fix` 说话,手打名字太慢 —— 直接引用它的气泡即可。每条出站气泡都
|
|
440
|
+
// 带 `emoji .name` 头 (withTagHeader),所以引用文本自带路由信息;用户自己发的
|
|
441
|
+
// 行首 `.fix 干活` 同样算数(限行首,否则正文里随手写的 .gitignore 会误判)。
|
|
434
442
|
// `body` 是剥掉头/tag 后的净引用内容,用于跟目标 context 比对。
|
|
435
443
|
// `tag` = 路由目标(引用继承的投递 tag)。
|
|
436
|
-
// `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji
|
|
444
|
+
// `srcTag` = 引用气泡真正出自哪个会话 —— 仅 bot 气泡可知(反解 `emoji .name` 头)。
|
|
437
445
|
// 去重要比对的是「内容在不在源会话」,而非路由目标: 带着引用新建 /
|
|
438
446
|
// 改投到别的 tag 时,目标会话是空的,只有源会话里才有那段原文。
|
|
439
447
|
// `srcTag===undefined` 表示源未知(用户自己打的引用),回退到按目标查。
|
|
440
|
-
const
|
|
448
|
+
const QUOTE_NAME_RE = nameTokenRe();
|
|
449
|
+
const parseQuote = (q, known) => {
|
|
441
450
|
const raw = q ? quoteToText(q).trim() : "";
|
|
442
451
|
if (!raw)
|
|
443
452
|
return null;
|
|
444
453
|
const head = parseTagHeader(raw);
|
|
445
454
|
if (head.fromBot)
|
|
446
455
|
return { tag: head.tag, srcTag: head.tag, body: head.body };
|
|
447
|
-
const m =
|
|
448
|
-
return m && m.index === 0 ? { tag: m[2] ?? "", body: parseTag(raw).cleaned } : { tag: "", body: raw };
|
|
456
|
+
const m = QUOTE_NAME_RE.exec(raw);
|
|
457
|
+
return m && m.index === 0 ? { tag: m[2] ?? "", body: parseTag(raw, known).cleaned } : { tag: "", body: raw };
|
|
449
458
|
};
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
// 2. 引用内容若已经在目标会话的 context 尾部,就只保留上面那层路由绑定、正文
|
|
454
|
-
// 丢弃(重复贴回去纯属污染);不在则说明它是真载荷(跨会话转发 / 引同事的
|
|
455
|
-
// 消息 / 目标已 `/clear`),照旧渲染成 markdown 引用块。
|
|
456
|
-
// 纯引用不打字时,沿用旧的"把引用内容提成正文"重触发路径 —— 但同样只在内容不
|
|
457
|
-
// 在目标上下文里时才有意义,否则那只是一次对该会话的空 nudge。
|
|
458
|
-
const composeInbound = (msg, rawBody, inContext, stripAt) => {
|
|
459
|
-
const { tag: typed, cleaned } = parseTag(rawBody);
|
|
460
|
-
const q = parseQuote(msg.quote);
|
|
459
|
+
const composeInbound = (msg, rawBody, inContext, stripAt, addr) => {
|
|
460
|
+
const { tag: typed, cleaned } = parseTag(rawBody, addr.known);
|
|
461
|
+
const q = parseQuote(msg.quote, addr.known);
|
|
461
462
|
const tag = typed || q?.tag || "";
|
|
462
463
|
// 去重比对的会话: 若引用来自某个 bot 会话(srcTag 已知),查那个源会话 ——
|
|
463
464
|
// 内容天然存在于源的 transcript, 与你把它投到哪个 tag 无关。源未知时(用户自打
|
|
464
465
|
// 的引用 / 改投)回退到路由目标。这修掉了「带引用新建/改投会话时原文被重复注入」。
|
|
465
466
|
const dedupTag = q?.srcTag ?? tag;
|
|
466
467
|
// 剥完头什么都不剩(折叠气泡这类纯 chrome 的引用)⇒ 没有可搬运的内容,只留路由。
|
|
467
|
-
const consumed = !q || !q.body.trim() || inContext(
|
|
468
|
+
const consumed = !q || !q.body.trim() || inContext(addr.resolve(chatPrincipal(msg), dedupTag), q.body);
|
|
468
469
|
if (cleaned.trim()) {
|
|
469
470
|
return { text: consumed ? cleaned : `${renderQuotePrefix(q.body)}${cleaned}`, tag, promoted: false };
|
|
470
471
|
}
|
|
471
472
|
if (q && !consumed) {
|
|
472
473
|
// 提成正文时也剥一次 @mention,让 "@wezard /usage" → "/usage" 命中命令路径。
|
|
473
|
-
const p = parseTag(stripAt(msg, q.body).trim());
|
|
474
|
+
const p = parseTag(stripAt(msg, q.body).trim(), addr.known);
|
|
474
475
|
return { text: p.cleaned, tag: p.tag || tag, promoted: true };
|
|
475
476
|
}
|
|
476
477
|
return { text: cleaned, tag, promoted: false };
|
|
@@ -486,9 +487,9 @@ const isAllowed = (cfg, principals) => {
|
|
|
486
487
|
return principals.some((p) => allowed.has(p));
|
|
487
488
|
};
|
|
488
489
|
// Mirror mode grants implicit talkback: any chat that's currently a mirror
|
|
489
|
-
// target can post back without being in `allowFrom`.
|
|
490
|
-
//
|
|
491
|
-
const isMirrorTarget = (bridge, who) =>
|
|
490
|
+
// target can post back without being in `allowFrom`. A session bound to that
|
|
491
|
+
// chat is the authorization signal.
|
|
492
|
+
const isMirrorTarget = (bridge, who) => bridge.hasMirrorTarget(who);
|
|
492
493
|
// Sniff extension from magic bytes; falls back to .bin. WeCom doesn't always
|
|
493
494
|
// give us a filename for images, and we want claude's Read tool to recognize
|
|
494
495
|
// the file (it dispatches on extension).
|
|
@@ -526,39 +527,33 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
526
527
|
// 已知的机器人名只随配置变; 下面所有入站路径共用这一个剥离器。
|
|
527
528
|
const botNames = [...BUILTIN_BOT_NAMES, ...cfg.wrc.botNames.filter(Boolean)];
|
|
528
529
|
const stripAt = (msg, text) => maybeStripMentions(botNames, msg, text);
|
|
529
|
-
// Render /pwd output
|
|
530
|
-
// store via bridge.getCwd; headless mode has no per-chat cwd, so it just
|
|
531
|
-
// shows cfg.wrc.cwd as the global default.
|
|
530
|
+
// Render /pwd output from the live attachment + persisted store (bridge.getCwd).
|
|
532
531
|
const renderPwd = (who) => {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
lines.push(`下次切换: \`${pendingCwd}\` (使用 /new 或 /clear 生效)`);
|
|
538
|
-
}
|
|
539
|
-
if (runningCwd !== defaultCwd)
|
|
540
|
-
lines.push(`(默认: \`${defaultCwd}\`)`);
|
|
541
|
-
lines.push("> 切换其他项目: 对 AI 说「切到 /path/to/proj」(`set_workspace` 工具直接换目录重开)");
|
|
542
|
-
return lines.join("\n");
|
|
532
|
+
const { runningCwd, pendingCwd, defaultCwd } = bridge.getCwd(who);
|
|
533
|
+
const lines = [`[wezard] 📂 当前项目: \`${runningCwd}\``];
|
|
534
|
+
if (pendingCwd && pendingCwd !== runningCwd) {
|
|
535
|
+
lines.push(`下次切换: \`${pendingCwd}\` (使用 /new 或 /clear 生效)`);
|
|
543
536
|
}
|
|
544
|
-
|
|
537
|
+
if (runningCwd !== defaultCwd)
|
|
538
|
+
lines.push(`(默认: \`${defaultCwd}\`)`);
|
|
539
|
+
if (bridge.cwdUnconfirmed(who))
|
|
540
|
+
lines.push("⚠️ 这是默认兜底目录, 还没人确认过 —— 要换就说「切到 /path/to/proj」");
|
|
541
|
+
lines.push("> 切换其他项目: 对 AI 说「切到 /path/to/proj」(`set_workspace` 工具直接换目录重开)");
|
|
542
|
+
return lines.join("\n");
|
|
545
543
|
};
|
|
546
|
-
//
|
|
544
|
+
// Auto-spawn / /new helper. Routes through bridge.newSession
|
|
547
545
|
// which kills the old pane, spawns fresh in pendingCwd ?? runningCwd ??
|
|
548
546
|
// default, attaches, and pushes "📂 当前项目" info to the chat. Returns
|
|
549
|
-
// the user-facing one-line ack.
|
|
550
|
-
//
|
|
551
|
-
//
|
|
547
|
+
// the user-facing one-line ack. The wizard's global name doubles as the
|
|
548
|
+
// tmux window name so the pane shows readably in the status bar (e.g.
|
|
549
|
+
// `.docs` → window `docs`, not the principal slug).
|
|
552
550
|
// On success there is NO reply: newSession already pushed the single
|
|
553
551
|
// "created + cwd" bubble. Only failures produce user-facing text.
|
|
554
552
|
const spawnSession = async (who, cli, silent, model) => {
|
|
555
|
-
|
|
556
|
-
return { err: "[wezard] /new only available in mirror mode" };
|
|
557
|
-
const tag = tagOf(who);
|
|
558
|
-
const r = await bridge.newSession(who, tag || who, cli, { silent, model });
|
|
553
|
+
const r = await bridge.newSession(who, displayName(who) || tagOf(who) || who, cli, { silent, model });
|
|
559
554
|
return r.ok ? {} : { err: `[wezard] /new failed: ${r.reason ?? "unknown"}` };
|
|
560
555
|
};
|
|
561
|
-
// 同一
|
|
556
|
+
// 同一 wizard 的两条消息会并发落进 gate,双双判定「未附着」→ 双 spawn,后者
|
|
562
557
|
// newSession 会 kill 掉前者的 pane,前者的 dispatch 再 `--resume` 重生出孤儿
|
|
563
558
|
// pane,消息乱序。spawn 窗口有 3s+(TUI_SETTLE_MS),所以必须按会话串行。
|
|
564
559
|
const spawnQ = new Map();
|
|
@@ -571,15 +566,17 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
571
566
|
return next;
|
|
572
567
|
};
|
|
573
568
|
// 显式 /new:排队但仍强制重开(用户就是要换一个)。
|
|
574
|
-
|
|
575
|
-
|
|
569
|
+
// 默认会话先把名字换回聊天名, 再 spawn —— charter 在 spawn 时按名字渲染。
|
|
570
|
+
const autoSpawnAndAttach = (who, cli, model) => serializeSpawn(who, () => {
|
|
571
|
+
reclaimChatName(wizardStore(), chatNameOf(cfg, who), who);
|
|
572
|
+
return spawnSession(who, cli, false, model);
|
|
573
|
+
});
|
|
574
|
+
// 隐式建会话(新 `.name` 的第一条消息):轮到自己时若前一条已经把会话建好,直接
|
|
576
575
|
// 复用,不再 respawn —— 否则先到的消息会被注入进一个刚被杀掉的 pane。
|
|
577
|
-
const ensureSession = (who) => serializeSpawn(who, async () =>
|
|
578
|
-
// Prefix user-visible daemon replies with `<emoji>
|
|
579
|
-
//
|
|
580
|
-
//
|
|
581
|
-
// unchanged. Emoji is derived from the tag string (not sessionId) so it
|
|
582
|
-
// stays stable across /clear cycles.
|
|
576
|
+
const ensureSession = (who) => serializeSpawn(who, async () => bridge.hasMirrorTarget(who) ? {} : await spawnSession(who, undefined, true));
|
|
577
|
+
// Prefix user-visible daemon replies with `<emoji> .name`, so a chat hosting
|
|
578
|
+
// several wizards stays visually disambiguated. Emoji is derived from the
|
|
579
|
+
// name (not sessionId) so it stays stable across /clear cycles.
|
|
583
580
|
const withTagPrefix = withTagHeader;
|
|
584
581
|
const replyText = async (frame, msg, who, text) => {
|
|
585
582
|
try {
|
|
@@ -592,8 +589,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
592
589
|
const gate = async (frame, msg, text, who) => {
|
|
593
590
|
const auths = authPrincipals(msg);
|
|
594
591
|
// Bootstrap / allowFrom operations are chat-scoped, not session-scoped;
|
|
595
|
-
//
|
|
596
|
-
// still promotes them as `user:xxx` (not `user:xxx#foo`).
|
|
592
|
+
// use the chat principal, not the routed wizard — a first-time user typing
|
|
593
|
+
// `.foo hello` still promotes them as `user:xxx` (not `user:xxx#foo`).
|
|
597
594
|
const basePrincipal = chatPrincipal(msg);
|
|
598
595
|
// /id — bypass allowFrom so users can discover their ids before configuring.
|
|
599
596
|
if (isIdCommand(text)) {
|
|
@@ -637,13 +634,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
637
634
|
// Untagged form falls back to the caller's own mirror binding.
|
|
638
635
|
const audit = parseAuditCommand(text);
|
|
639
636
|
if (audit) {
|
|
640
|
-
const mirror =
|
|
641
|
-
? resolveAuditMirror(bridge.status().mirrors, audit.tag, who, chatPrincipal(msg))
|
|
642
|
-
: undefined;
|
|
637
|
+
const mirror = resolveAuditMirror(bridge.status().mirrors, audit.tag, who, chatPrincipal(msg));
|
|
643
638
|
let body;
|
|
644
639
|
if (!mirror) {
|
|
645
640
|
body = audit.tag
|
|
646
|
-
? `[wezard] /audit: 未找到
|
|
641
|
+
? `[wezard] /audit: 未找到 \`.${audit.tag}\` 对应的 Agent 会话。`
|
|
647
642
|
: `[wezard] /audit: 未找到 ${who} 绑定的 Agent 会话。先 \`/new\` 或用 \`wezard mirror\` 绑定后再试。`;
|
|
648
643
|
}
|
|
649
644
|
else {
|
|
@@ -725,8 +720,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
725
720
|
}
|
|
726
721
|
// Authorized `/new` — spawn a tmux+claude pair and attach it to this chat.
|
|
727
722
|
// Runs BEFORE the mirror-not-attached short-circuit so it works as the
|
|
728
|
-
// very first message from a fresh user. When routed with a
|
|
729
|
-
//
|
|
723
|
+
// very first message from a fresh user. When routed with a `.name`, it
|
|
724
|
+
// picks which wizard (re)spawns; the name is also the tmux window name.
|
|
730
725
|
const nu = parseNewCommand(text);
|
|
731
726
|
if (nu) {
|
|
732
727
|
const { err } = await autoSpawnAndAttach(who, nu.cli, nu.model);
|
|
@@ -741,62 +736,45 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
741
736
|
return { stop: true };
|
|
742
737
|
}
|
|
743
738
|
// Authorized `/stop` — Esc the live pane to interrupt whatever Claude is
|
|
744
|
-
// currently doing.
|
|
739
|
+
// currently doing. Bails cleanly when no attachment.
|
|
745
740
|
if (isStopCommand(text)) {
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
await replyText(frame, msg, who, `⚠️ Esc 未送达(${r.escReason ?? "unknown"})${torndown} · 保活已暂停`);
|
|
759
|
-
}
|
|
741
|
+
// teardown: /stop is the user's "shut this up" button, so it must also
|
|
742
|
+
// close hanging bubbles and free the inject queue — not just press Esc.
|
|
743
|
+
// Stay silent on a clean stop (the pane going quiet IS the receipt);
|
|
744
|
+
// only speak up when a half actually failed — a live pane that refuses
|
|
745
|
+
// Esc is a very different situation from a chat merely stuck on a bubble.
|
|
746
|
+
const r = await bridge.interruptPane(who, { teardown: true });
|
|
747
|
+
if (!r.ok) {
|
|
748
|
+
await replyText(frame, msg, who, `[wezard] /stop failed: ${r.reason ?? "unknown"}`);
|
|
749
|
+
}
|
|
750
|
+
else if (!r.escOk) {
|
|
751
|
+
const torndown = r.torndown ? ` · 已收口 ${r.torndown} 个挂起气泡` : "";
|
|
752
|
+
await replyText(frame, msg, who, `⚠️ Esc 未送达(${r.escReason ?? "unknown"})${torndown} · 保活已暂停`);
|
|
760
753
|
}
|
|
761
754
|
return { stop: true };
|
|
762
755
|
}
|
|
763
756
|
// Authorized `/kill` — end this session for good: Esc the pane, kill it,
|
|
764
|
-
// and drop the binding (no `--resume` resurrection). Routed by
|
|
757
|
+
// and drop the binding (no `--resume` resurrection). Routed by `.name` like
|
|
765
758
|
// /stop, so `/kill #docs` only takes down that sibling.
|
|
766
759
|
if (isKillCommand(text)) {
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
}
|
|
770
|
-
else {
|
|
771
|
-
const r = await bridge.killPane(who);
|
|
772
|
-
await replyText(frame, msg, who, r.ok ? "🗑️ 会话已结束,pane 已移除" : `[wezard] /kill failed: ${r.reason ?? "unknown"}`);
|
|
773
|
-
}
|
|
760
|
+
const r = await bridge.killPane(who);
|
|
761
|
+
await replyText(frame, msg, who, r.ok ? "🗑️ 会话已结束,pane 已移除" : `[wezard] /kill failed: ${r.reason ?? "unknown"}`);
|
|
774
762
|
return { stop: true };
|
|
775
763
|
}
|
|
776
764
|
// Authorized `/n` — send a bare Enter to the live pane. Confirms a prompt /
|
|
777
765
|
// dismisses a "press enter to continue", or submits whatever's already in
|
|
778
|
-
// the input box.
|
|
766
|
+
// the input box. Bails cleanly when no attachment.
|
|
779
767
|
if (isEnterCommand(text)) {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
}
|
|
783
|
-
else {
|
|
784
|
-
const r = await bridge.submitPane(who);
|
|
785
|
-
await replyText(frame, msg, who, r.ok ? "Enter sent" : `[wezard] /n failed: ${r.reason ?? "unknown"}`);
|
|
786
|
-
}
|
|
768
|
+
const r = await bridge.submitPane(who);
|
|
769
|
+
await replyText(frame, msg, who, r.ok ? "Enter sent" : `[wezard] /n failed: ${r.reason ?? "unknown"}`);
|
|
787
770
|
return { stop: true };
|
|
788
771
|
}
|
|
789
772
|
// Authorized `/reveal` — switch the attached tmux client to this session's
|
|
790
|
-
// pane so the user lands in the terminal showing the live TUI.
|
|
791
|
-
//
|
|
773
|
+
// pane so the user lands in the terminal showing the live TUI. Routed by
|
|
774
|
+
// `.name` like any other session command.
|
|
792
775
|
if (isRevealCommand(text)) {
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
}
|
|
796
|
-
else {
|
|
797
|
-
const r = await bridge.revealPane(who);
|
|
798
|
-
await replyText(frame, msg, who, r.ok ? "✅ 已切到本会话的 tmux 窗口" : `[wezard] /reveal failed: ${r.reason ?? "unknown"}`);
|
|
799
|
-
}
|
|
776
|
+
const r = await bridge.revealPane(who);
|
|
777
|
+
await replyText(frame, msg, who, r.ok ? "✅ 已切到本会话的 tmux 窗口" : `[wezard] /reveal failed: ${r.reason ?? "unknown"}`);
|
|
800
778
|
return { stop: true };
|
|
801
779
|
}
|
|
802
780
|
// /cfgsync [apply] — 3-way merge of the bound project's per-CLI config
|
|
@@ -804,7 +782,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
804
782
|
// .codebuddy/...). Writes files, so it sits AFTER the allowFrom gate.
|
|
805
783
|
const cs = parseCfgSyncCommand(text);
|
|
806
784
|
if (cs) {
|
|
807
|
-
const cwd =
|
|
785
|
+
const cwd = bridge.getCwd(who).runningCwd;
|
|
808
786
|
let body;
|
|
809
787
|
try {
|
|
810
788
|
body = renderSyncReport(await syncProjectConfig(cwd, cs.apply));
|
|
@@ -824,8 +802,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
824
802
|
const cur = chatNameOf(cfg, who);
|
|
825
803
|
if (!nc.arg) {
|
|
826
804
|
await replyText(frame, msg, who, cur
|
|
827
|
-
? `[wezard] 本聊天名为 \`${cur}
|
|
828
|
-
: "[wezard] 本聊天还没起名。`/name <名字>`
|
|
805
|
+
? `[wezard] 本聊天名为 \`${cur}\``
|
|
806
|
+
: "[wezard] 本聊天还没起名。`/name <名字>` 起一个。");
|
|
829
807
|
return { stop: true };
|
|
830
808
|
}
|
|
831
809
|
if (nc.arg === "-") {
|
|
@@ -835,26 +813,18 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
835
813
|
}
|
|
836
814
|
const r = setChatName(cfg, sourcePath, who, nc.arg);
|
|
837
815
|
await replyText(frame, msg, who, r.ok
|
|
838
|
-
? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""}
|
|
816
|
+
? `[wezard] ✅ 本聊天更名为 \`${r.name}\`${cur && cur !== r.name ? `(原 \`${cur}\`)` : ""}`
|
|
839
817
|
: `[wezard] /name failed: ${r.reason}`);
|
|
840
818
|
return { stop: true };
|
|
841
819
|
}
|
|
842
820
|
// /chats — 跨聊天目录:谁有名字、各自跑着哪些会话。Read-only。
|
|
843
821
|
if (isChatsCommand(text)) {
|
|
844
|
-
if (!("chatRoster" in bridge)) {
|
|
845
|
-
await replyText(frame, msg, who, `[wezard] headless 模式无会话目录。已命名的聊天:${listChatNames(cfg).map((c) => c.name).join(", ") || "(无)"}`);
|
|
846
|
-
return { stop: true };
|
|
847
|
-
}
|
|
848
822
|
await replyText(frame, msg, who, renderChats(bridge.chatRoster(who)));
|
|
849
823
|
return { stop: true };
|
|
850
824
|
}
|
|
851
|
-
// /peers — this chat's own
|
|
852
|
-
// live busy state. Read-only
|
|
825
|
+
// /peers — this chat's own wizard roster (default + siblings), with
|
|
826
|
+
// live busy state. Read-only.
|
|
853
827
|
if (isPeersCommand(text)) {
|
|
854
|
-
if (!("peers" in bridge)) {
|
|
855
|
-
await replyText(frame, msg, who, "[wezard] /peers only available in mirror mode");
|
|
856
|
-
return { stop: true };
|
|
857
|
-
}
|
|
858
828
|
let body;
|
|
859
829
|
try {
|
|
860
830
|
body = renderPeers(await bridge.peers(who), chatNameOf(cfg, who));
|
|
@@ -871,10 +841,6 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
871
841
|
// path as the /sessions/switch route so IM and MCP behave identically.
|
|
872
842
|
const sc = parseSessionsCommand(text);
|
|
873
843
|
if (sc) {
|
|
874
|
-
if (!("attach" in bridge)) {
|
|
875
|
-
await replyText(frame, msg, who, "[wezard] /sessions only available in mirror mode");
|
|
876
|
-
return { stop: true };
|
|
877
|
-
}
|
|
878
844
|
let sessions = [];
|
|
879
845
|
try {
|
|
880
846
|
sessions = await scanClaudeSessions();
|
|
@@ -904,11 +870,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
904
870
|
: `[wezard] 切换失败: ${att.reason ?? "unknown"}`);
|
|
905
871
|
return { stop: true };
|
|
906
872
|
}
|
|
907
|
-
//
|
|
873
|
+
// No Claude session attached for this chat yet. Since the
|
|
908
874
|
// sender is already in allowFrom, we treat that authorization as license
|
|
909
875
|
// to auto-spawn: this inbound becomes both the binding signal and the
|
|
910
876
|
// first prompt — attach, then fall through to dispatch.
|
|
911
|
-
if (
|
|
877
|
+
if (!bridge.hasMirrorTarget(who)) {
|
|
912
878
|
const { err } = await ensureSession(who);
|
|
913
879
|
if (err) {
|
|
914
880
|
await replyText(frame, msg, who, err);
|
|
@@ -919,9 +885,9 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
919
885
|
return { stop: false };
|
|
920
886
|
};
|
|
921
887
|
// 「引用内容是否已经在目标会话的 context 里」。两级:先查刚发出去的最后一条
|
|
922
|
-
// 气泡(
|
|
923
|
-
// —— 引用的往往是几轮之前的气泡,只比对最后一条会漏。目标未挂载(尚未 attach
|
|
924
|
-
//
|
|
888
|
+
// 气泡(内存);miss 再读目标会话 transcript 的尾部若干轮
|
|
889
|
+
// —— 引用的往往是几轮之前的气泡,只比对最后一条会漏。目标未挂载(尚未 attach)
|
|
890
|
+
// 时读不到 transcript,退化成"保留引用",宁可多给上下文。
|
|
925
891
|
const quoteInContext = (target, quoted) => {
|
|
926
892
|
if (isLastResponseQuote(baseOfKey(target), quoted)) {
|
|
927
893
|
log.info({ target, reason: "lastResponse" }, "quoteInContext: hit");
|
|
@@ -929,12 +895,11 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
929
895
|
}
|
|
930
896
|
// 源会话的 last stream 还没收口 ⇒ 引用的是实时中间态 (URL + 最新 CoT/工具行),
|
|
931
897
|
// 只保留路由 tag, 不把瞬态内容贴回 prompt。
|
|
932
|
-
if (
|
|
898
|
+
if (bridge.isOpenBubbleQuote(target, quoted)) {
|
|
933
899
|
log.info({ target, reason: "openBubble" }, "quoteInContext: hit");
|
|
934
900
|
return true;
|
|
935
901
|
}
|
|
936
|
-
const mirrors = bridge
|
|
937
|
-
.status?.().mirrors ?? [];
|
|
902
|
+
const mirrors = bridge.status().mirrors;
|
|
938
903
|
const jsonl = mirrors.find((m) => m.target === target)?.jsonlPath;
|
|
939
904
|
if (!jsonl) {
|
|
940
905
|
log.info({ target, mirrorCount: mirrors.length, mirrorTargets: mirrors.map((m) => m.target) }, "quoteInContext: no jsonl for target");
|
|
@@ -947,69 +912,40 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
947
912
|
log.info({ target, jsonl, tailLen: tail.length, quotedLen: quoted.length, hit }, "quoteInContext: tail check");
|
|
948
913
|
return hit;
|
|
949
914
|
};
|
|
950
|
-
// 路由用掉的那个
|
|
951
|
-
//
|
|
952
|
-
// 同一套(
|
|
953
|
-
//
|
|
954
|
-
// 自指静默略过。给的是 `address` 而不是裸 tag —— 跨 chat 时裸 tag 未必唯一。
|
|
915
|
+
// 路由用掉的那个 `.name` 已被 parseTag 摘走,正文里剩下的每个 `.x` 都可能是
|
|
916
|
+
// 用户在指另一个 wizard。解析走 bridge 自己的 resolvePeerTag —— peer 工具用的
|
|
917
|
+
// 同一套(全局名册),所以标注出来的地址一定是 peek_peer/send_peer 打得中的;
|
|
918
|
+
// 解析不到的 `.foo` 与自指静默略过。
|
|
955
919
|
const peerMentions = (who, text) => {
|
|
956
|
-
if (!("resolvePeerTag" in bridge))
|
|
957
|
-
return [];
|
|
958
920
|
const mb = bridge;
|
|
959
921
|
const seen = new Set();
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
if (!r.ok || r.target === who)
|
|
922
|
+
return allNames(text).flatMap((name) => {
|
|
923
|
+
const r = mb.resolvePeerTag(who, name);
|
|
924
|
+
if (!r.ok || r.target === who || seen.has(r.target))
|
|
964
925
|
return [];
|
|
965
926
|
seen.add(r.target);
|
|
966
927
|
const { runningCwd, defaultCwd } = mb.getCwd(r.target);
|
|
967
928
|
return [{
|
|
968
|
-
tag,
|
|
929
|
+
tag: name,
|
|
969
930
|
target: r.target,
|
|
970
931
|
address: peerAddress(cfg, who, r.target),
|
|
971
932
|
chat: chatNameOf(cfg, r.target),
|
|
972
933
|
foreign: r.foreign,
|
|
973
|
-
label: labelFor(
|
|
934
|
+
label: labelFor(peerAddress(cfg, who, r.target)),
|
|
974
935
|
cwd: runningCwd || defaultCwd,
|
|
975
936
|
}];
|
|
976
937
|
});
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
tag: addr.tag,
|
|
989
|
-
target: r.target,
|
|
990
|
-
address: peerAddress(cfg, who, r.target),
|
|
991
|
-
chat: chatNameOf(cfg, r.target),
|
|
992
|
-
foreign: r.foreign,
|
|
993
|
-
label: labelFor(addr.tag),
|
|
994
|
-
cwd: runningCwd || defaultCwd,
|
|
995
|
-
}];
|
|
996
|
-
}
|
|
997
|
-
// Session doesn't exist but chat name is valid → unborn peer hint
|
|
998
|
-
if (chatBaseOf(cfg, addr.chat)) {
|
|
999
|
-
return [{
|
|
1000
|
-
tag: addr.tag,
|
|
1001
|
-
target: "",
|
|
1002
|
-
address: `${addr.chat}#${addr.tag}`,
|
|
1003
|
-
chat: addr.chat,
|
|
1004
|
-
foreign: true,
|
|
1005
|
-
label: labelFor(addr.tag),
|
|
1006
|
-
cwd: "",
|
|
1007
|
-
unborn: true,
|
|
1008
|
-
}];
|
|
1009
|
-
}
|
|
1010
|
-
return [];
|
|
1011
|
-
});
|
|
1012
|
-
return [...fromTags, ...fromCompound];
|
|
938
|
+
};
|
|
939
|
+
// 名字解析的两个问题: 这个名字认不认识 (决定正文里哪个 `.x` 是路由)、它指向谁。
|
|
940
|
+
const live = (t) => bridge.hasMirrorTarget(t);
|
|
941
|
+
const addrFor = (base) => ({
|
|
942
|
+
known: (name) => !!wizardStore()?.byName(name) || live(keyOf(base, normalizeTag(name))),
|
|
943
|
+
resolve: (b, name) => resolveName(b, name, live),
|
|
944
|
+
});
|
|
945
|
+
const route = (msg, raw) => {
|
|
946
|
+
const base = chatPrincipal(msg);
|
|
947
|
+
const c = composeInbound(msg, raw, quoteInContext, stripAt, addrFor(base));
|
|
948
|
+
return { ...c, who: resolveName(base, c.tag, live) };
|
|
1013
949
|
};
|
|
1014
950
|
const send = async (frame, msg, who, text, images = []) => {
|
|
1015
951
|
// 斜杠命令按行解析,尾巴上多挂一段会让它不再被识别成命令 —— 只标注普通消息。
|
|
@@ -1017,7 +953,8 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
1017
953
|
// 同一条边界上再挂一段: 这个 wizard 不在场时群里发生的成员变动 (见 notices.ts)。
|
|
1018
954
|
const notice = noticeSuffixFor(who, text);
|
|
1019
955
|
try {
|
|
1020
|
-
|
|
956
|
+
// 回复回到发话的这个群 —— `who` 可能住在别的聊天 (名字全局可达)。
|
|
957
|
+
await bridge.dispatch({ principal: who, text: text + hint + notice, images, frame, streamId: msg.msgid, channel: chatPrincipal(msg), speaker: `user:${msg.from.userid}` });
|
|
1021
958
|
}
|
|
1022
959
|
catch (e) {
|
|
1023
960
|
log.error({ err: e.message }, "bridge dispatch failed");
|
|
@@ -1031,8 +968,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
1031
968
|
const msg = frame.body;
|
|
1032
969
|
if (!msg)
|
|
1033
970
|
return;
|
|
1034
|
-
const { text, tag, promoted } =
|
|
1035
|
-
const who = sessionKey(chatPrincipal(msg), tag);
|
|
971
|
+
const { text, tag, promoted, who } = route(msg, stripAt(msg, msg.text?.content ?? ""));
|
|
1036
972
|
log.info({ msgid: msg.msgid, len: text.length, tag, hasQuote: !!msg.quote, promoted }, "rx text");
|
|
1037
973
|
const { stop } = await gate(frame, msg, text, who);
|
|
1038
974
|
if (stop)
|
|
@@ -1046,8 +982,7 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
1046
982
|
log.info({ msgid: msg.msgid, hasQuote: !!msg.quote }, "rx image");
|
|
1047
983
|
// Images carry no text of their own — the quote (if any) is the only
|
|
1048
984
|
// routing signal; without it they land on the chat's default session.
|
|
1049
|
-
const { text,
|
|
1050
|
-
const who = sessionKey(chatPrincipal(msg), tag);
|
|
985
|
+
const { text, who } = route(msg, "");
|
|
1051
986
|
const { stop } = await gate(frame, msg, "", who);
|
|
1052
987
|
if (stop)
|
|
1053
988
|
return;
|
|
@@ -1070,14 +1005,13 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
1070
1005
|
if (!msg)
|
|
1071
1006
|
return;
|
|
1072
1007
|
log.info({ msgid: msg.msgid, items: msg.mixed?.msg_item?.length, hasQuote: !!msg.quote }, "rx mixed");
|
|
1073
|
-
// Concatenate all text items to sniff a
|
|
1008
|
+
// Concatenate all text items to sniff a routing `.name`, then strip it from
|
|
1074
1009
|
// the effective body before forwarding to Claude.
|
|
1075
1010
|
const rawText = (msg.mixed?.msg_item ?? [])
|
|
1076
1011
|
.filter((it) => it.msgtype === "text")
|
|
1077
1012
|
.map((it) => it.text?.content ?? "")
|
|
1078
1013
|
.join("\n");
|
|
1079
|
-
const {
|
|
1080
|
-
const who = sessionKey(chatPrincipal(msg), tag);
|
|
1014
|
+
const { who } = route(msg, stripAt(msg, rawText));
|
|
1081
1015
|
const { stop } = await gate(frame, msg, "", who);
|
|
1082
1016
|
if (stop)
|
|
1083
1017
|
return;
|
|
@@ -1098,10 +1032,10 @@ export const installInboundRouter = (client, cfg, log, bridge, sourcePath) => {
|
|
|
1098
1032
|
}
|
|
1099
1033
|
if (texts.length === 0 && images.length === 0 && !msg.quote)
|
|
1100
1034
|
return;
|
|
1101
|
-
// Re-compose on the per-item stripped text: drops the routing
|
|
1035
|
+
// Re-compose on the per-item stripped text: drops the routing `.name` (it was
|
|
1102
1036
|
// consumed above; leaving it in would leak into Claude) and attaches the
|
|
1103
1037
|
// quote only when it isn't already in the target's context.
|
|
1104
|
-
await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt).text, images);
|
|
1038
|
+
await send(frame, msg, who, composeInbound(msg, texts.join("\n"), quoteInContext, stripAt, addrFor(chatPrincipal(msg))).text, images);
|
|
1105
1039
|
});
|
|
1106
1040
|
// template_card_event is handled in approval module; no listener here.
|
|
1107
1041
|
};
|