wezard 2.4.0 → 2.5.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.
- package/CLAUDE.md +1 -1
- package/README.md +113 -57
- package/cli/wezard.sh +2 -0
- package/config.example.jsonc +7 -4
- package/dist/cli/wx.js +78 -0
- package/dist/cli/wx.js.map +1 -0
- package/dist/daemon/aibot-media.js +62 -0
- package/dist/daemon/aibot-media.js.map +1 -0
- package/dist/daemon/approval.js +30 -20
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/ask.js +3 -2
- package/dist/daemon/ask.js.map +1 -1
- package/dist/daemon/cc-session.js +27 -4
- package/dist/daemon/cc-session.js.map +1 -1
- package/dist/daemon/chat-log.js +2 -2
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +6 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +25 -10
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/direction.js +53 -0
- package/dist/daemon/direction.js.map +1 -0
- package/dist/daemon/ilink.js +162 -0
- package/dist/daemon/ilink.js.map +1 -0
- package/dist/daemon/inbound.js +64 -18
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +415 -109
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +4 -3
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +6 -3
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/media.js +108 -0
- package/dist/daemon/media.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +111 -93
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +159 -54
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +59 -12
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/outbound.js +2 -1
- package/dist/daemon/outbound.js.map +1 -1
- package/dist/daemon/peers.js +46 -27
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +1 -1
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/reach.js +6 -0
- package/dist/daemon/reach.js.map +1 -0
- package/dist/daemon/receipts.js +149 -7
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +11 -4
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +157 -60
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +48 -7
- package/dist/daemon/steward-guard.js.map +1 -1
- package/dist/daemon/top-only-nudge.js +29 -0
- package/dist/daemon/top-only-nudge.js.map +1 -0
- package/dist/daemon/weixin-cmd.js +268 -0
- package/dist/daemon/weixin-cmd.js.map +1 -0
- package/dist/daemon/weixin-port.js +226 -0
- package/dist/daemon/weixin-port.js.map +1 -0
- package/dist/daemon/weixin.js +772 -0
- package/dist/daemon/weixin.js.map +1 -0
- package/dist/daemon/wizard.js +56 -18
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +43 -23
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +61 -20
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +9 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +16 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +3 -0
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +45 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/json-map-store.js +1 -0
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/reminder.js +1 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +7 -4
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +116 -37
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/seen-log.js +26 -0
- package/dist/shared/seen-log.js.map +1 -0
- package/dist/shared/session-label.js +84 -31
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/turn-state.js +1 -1
- package/dist/shared/turn-state.js.map +1 -1
- package/dist/shared/world.js.map +1 -1
- package/dist/shared/wx-text.js +101 -0
- package/dist/shared/wx-text.js.map +1 -0
- package/dist/svr/index.js +5 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +9 -6
- package/package.json +6 -2
- package/web/chat.css +32 -5
- package/web/chat.js +160 -92
package/dist/daemon/index.js
CHANGED
|
@@ -12,6 +12,11 @@ import { patchJsonc } from "../shared/config-writer.js";
|
|
|
12
12
|
import { EFFORTS, parseEffort } from "../shared/effort.js";
|
|
13
13
|
import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
|
|
14
14
|
import { startWs } from "./ws.js";
|
|
15
|
+
import { startWeixin } from "./weixin.js";
|
|
16
|
+
import { installWeixinPort } from "./weixin-port.js";
|
|
17
|
+
import { bindWxCommands, makeOnBound, makeWxCommands, routeSendMedia, wecomBindSink, weixinSendMedia, wxAdmins, wxRoutes } from "./weixin-cmd.js";
|
|
18
|
+
import { bindChatReach } from "./reach.js";
|
|
19
|
+
import { aibotSendMedia } from "./aibot-media.js";
|
|
15
20
|
import { startNetWatch } from "./net-watch.js";
|
|
16
21
|
import { startHttp, json, readBody } from "./http.js";
|
|
17
22
|
import { parsePath } from "../shared/config-meta.js";
|
|
@@ -36,13 +41,15 @@ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderT
|
|
|
36
41
|
import { openTaskRegistry } from "./task-registry.js";
|
|
37
42
|
import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
|
|
38
43
|
import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
|
|
39
|
-
import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
44
|
+
import { baseOfKey, bindTagLinker, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
|
|
40
45
|
import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
|
|
41
46
|
import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
|
|
42
|
-
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
|
|
47
|
+
import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
|
|
48
|
+
import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
|
|
43
49
|
import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
|
|
44
50
|
import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
|
|
45
51
|
import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
|
|
52
|
+
import { checkDirection } from "./direction.js";
|
|
46
53
|
import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
|
|
47
54
|
import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
|
|
48
55
|
import { keepalivePingSigs } from "../shared/keepalive.js";
|
|
@@ -53,7 +60,7 @@ import { renderInFlight } from "../shared/turn-state.js";
|
|
|
53
60
|
import { startIdleNudge } from "./idle-nudge.js";
|
|
54
61
|
import { renderReminder } from "../shared/reminder.js";
|
|
55
62
|
import { createHandoffs, handedOff, handingOff } from "./handoff.js";
|
|
56
|
-
import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
|
|
63
|
+
import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
|
|
57
64
|
import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
|
|
58
65
|
import { audienceOf } from "../shared/role-view.js";
|
|
59
66
|
import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
|
|
@@ -100,11 +107,44 @@ const main = async () => {
|
|
|
100
107
|
// 切网 (换 WiFi / VPN 起停) 后原地重建 WS, 等价于自动做了一次 reload 的
|
|
101
108
|
// 联通性部分 — 但保留全部内存态 (graph 运行、pending 长轮询、镜像绑定),
|
|
102
109
|
// 也不依赖 launchd/systemd 的 respawn 策略 (nohup fallback 没有 supervisor)。
|
|
103
|
-
const netWatch = startNetWatch(log.child({ mod: "net" }), (from, to) =>
|
|
110
|
+
const netWatch = startNetWatch(log.child({ mod: "net" }), (from, to) => {
|
|
111
|
+
ws.reconnect(`network changed: ${from || "<offline>"} → ${to}`);
|
|
112
|
+
wx.kick(`network changed → ${to}`);
|
|
113
|
+
});
|
|
114
|
+
// 微信 ClawBot: 每个扫码绑定的微信用户 = 一个群聊 `chat:wx_…`。分流层装在 tracker 里面 (先装) ——
|
|
115
|
+
// tracker 的空消息 gate / last-response 记录照样覆盖微信; 其余模块照旧只认 ws.client。
|
|
116
|
+
// 这里只建状态, 收发要等下面的入站监听全装好 (wx.start)。onBound 用到的 bridge 在下面
|
|
117
|
+
// 才立起来, 绑定只会在开机很久之后发生, 闭包里取得到。
|
|
118
|
+
const wxLog = log.child({ mod: "weixin" });
|
|
119
|
+
// `/wx` 命令与续上的绑定共用的企微出口 (原生 aibot, 不经微信分流)。
|
|
120
|
+
const wxPush = {
|
|
121
|
+
log: wxLog,
|
|
122
|
+
sendWecom: (p, md) => void ws.client
|
|
123
|
+
.sendMessage(baseOfKey(p).replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content: md } })
|
|
124
|
+
.catch((e) => wxLog.warn({ to: p, err: errText(e) }, "wx cmd push failed")),
|
|
125
|
+
sendWecomMedia: aibotSendMedia(ws.client, log.child({ mod: "media" })),
|
|
126
|
+
};
|
|
127
|
+
const wx = startWeixin({
|
|
128
|
+
cfg,
|
|
129
|
+
log: wxLog,
|
|
130
|
+
sinkFor: (to) => wecomBindSink(wxPush, to),
|
|
131
|
+
onBound: (acct, name) => makeOnBound({
|
|
132
|
+
cfg, sourcePath, log: wxLog,
|
|
133
|
+
hasSession: (t) => bridge.hasMirrorTarget(t),
|
|
134
|
+
newSession: (t, win) => bridge.newSession(t, win, undefined, { silent: true }),
|
|
135
|
+
})(acct, name),
|
|
136
|
+
notifyWecom: (p, md) => void ws.client
|
|
137
|
+
.sendMessage(baseOfKey(p).replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content: md } })
|
|
138
|
+
.catch((e) => wxLog.warn({ to: p, err: errText(e) }, "weixin → wecom notice failed")),
|
|
139
|
+
});
|
|
140
|
+
installWeixinPort(ws.client, wx, wxLog);
|
|
141
|
+
// 审批 / ask / outbound 的「送得到吗」: 微信 chat 看它自己的号, 不看企微长连接。
|
|
142
|
+
bindChatReach((c) => (wx.owns(c) ? wx.live(c) : undefined));
|
|
104
143
|
// Wrap replyStream / replyStreamWithCard before any module sends —
|
|
105
144
|
// last-response tracker enables inbound's `quote` dedup of bot self-replies,
|
|
106
145
|
// and its chat-gate drops header-only pushes (empty messages) daemon-wide.
|
|
107
|
-
|
|
146
|
+
// 微信出站不走企微长连接, 不必等它连上。
|
|
147
|
+
installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
|
|
108
148
|
const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
|
|
109
149
|
// 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
|
|
110
150
|
// (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
|
|
@@ -131,10 +171,13 @@ const main = async () => {
|
|
|
131
171
|
return t && known(t) ? t : undefined;
|
|
132
172
|
},
|
|
133
173
|
nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
|
|
174
|
+
voiceOf: (t) => wizardStore()?.get(t)?.voiceOf,
|
|
134
175
|
});
|
|
135
176
|
// 人话也过交接的闸 (见 handoff.ts): 交接期间说的话进新会话, 不被旧会话带走、也不在
|
|
136
177
|
// 重开的空档里 resume 回旧 sid。
|
|
137
|
-
|
|
178
|
+
// 宪章守卫 (见「改配置会不会动到宪章」): 要等下面宪章渲染那一块立起来才有; 之前 (启动时那次自动补名) 只写不比对。
|
|
179
|
+
let guardCharter = (write) => ({ r: write(), note: Promise.resolve("") });
|
|
180
|
+
installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath, (w, c) => guardCharter(w, c));
|
|
138
181
|
// approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
|
|
139
182
|
// 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
|
|
140
183
|
// 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
|
|
@@ -187,7 +230,26 @@ const main = async () => {
|
|
|
187
230
|
http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
|
|
188
231
|
for (const [key, handler] of Object.entries(chatHandlers()))
|
|
189
232
|
http.register(key, handler);
|
|
233
|
+
// 终端 / 持口令的进程发起绑定: 先推确认卡给审批人 (口令每个 wizard 都读得到), 点了才出二维码。
|
|
234
|
+
const confirmBind = async (name) => {
|
|
235
|
+
const to = wxAdmins(cfg)[0];
|
|
236
|
+
if (!to)
|
|
237
|
+
throw new Error("没有审批人 (approval.approvers / defaultChat / allowFrom 里都没有 user:)");
|
|
238
|
+
const { decided } = await requestConfirm({
|
|
239
|
+
client: ws.client, principal: to, who: "本机 `wezard wx bind`",
|
|
240
|
+
title: "微信绑定待确认",
|
|
241
|
+
body: `本机有进程 (终端或持 daemon 口令的 wizard) 要绑定一个新微信${name ? ` (群名「${name}」)` : ""}: 同意才会出二维码, 扫码的那个微信将能驱动本机。`,
|
|
242
|
+
timeoutMs: 180_000,
|
|
243
|
+
});
|
|
244
|
+
return decided;
|
|
245
|
+
};
|
|
246
|
+
for (const [key, handler] of Object.entries(wxRoutes({ cfg, wx, sourcePath, confirmBind })))
|
|
247
|
+
http.register(key, guarded(handler));
|
|
248
|
+
bindWxCommands(makeWxCommands({ cfg, sourcePath, wx, ...wxPush }));
|
|
190
249
|
installAskEventListener(ws.client, log.child({ mod: "ask" }));
|
|
250
|
+
// 入站监听 (inbound / approval / ask) 都装好了才开收: 首轮 getupdates 会立刻带回 reload 期间
|
|
251
|
+
// 人发的话 (含回菜单的数字), 游标随即前移 —— 早了就是永久丢。
|
|
252
|
+
wx.start();
|
|
191
253
|
// 智能机器人 doc / smartsheet / contact MCP 桥接 — 总是注册路由, 失败让
|
|
192
254
|
// 错误透传到上游 (Claude / curl)。requesterUserId 解析顺序:
|
|
193
255
|
// 调用方 body.requesterUserId → defaultChat 里 user:<id> 部分 → 不传
|
|
@@ -548,24 +610,31 @@ const main = async () => {
|
|
|
548
610
|
const raw = (body.name ?? "").toString().trim();
|
|
549
611
|
// "-" is the same erase gesture `/name -` uses in the chat — one verb,
|
|
550
612
|
// one meaning, whether a human or an agent performs it.
|
|
613
|
+
// 聊天名进宪章: 过宪章守卫, 受影响的 wizard 被提醒 handoff, 调用方自己受影响的那段话放在 `charter`。
|
|
614
|
+
const byHuman = !!askerOf(self)?.who.startsWith("user:");
|
|
615
|
+
const charterOf = async (note) => ((t) => (t ? { charter: t } : {}))((await note).trim());
|
|
551
616
|
if (raw === "-") {
|
|
552
|
-
const gone = clearChatName(cfg, sourcePath, self);
|
|
553
|
-
json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone });
|
|
617
|
+
const { r: gone, note } = guardCharter(() => clearChatName(cfg, sourcePath, self), (g) => g ? { what: `\`name_chat\` 取消了聊天名 \`${g}\``, self, byHuman } : undefined);
|
|
618
|
+
json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone, ...(await charterOf(note)) });
|
|
554
619
|
return;
|
|
555
620
|
}
|
|
556
621
|
const previous = chatNameOf(cfg, self);
|
|
557
|
-
const
|
|
622
|
+
const home = baseOfKey(self);
|
|
623
|
+
const { r, note } = guardCharter(() => {
|
|
624
|
+
const r = setChatName(cfg, sourcePath, self, raw);
|
|
625
|
+
if (!r.ok)
|
|
626
|
+
return r;
|
|
627
|
+
// 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
|
|
628
|
+
const cur = wizards.get(home)?.name ?? "";
|
|
629
|
+
return { ...r, wizardRenamed: previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined };
|
|
630
|
+
}, (r) => r.ok && r.name !== previous ? { what: `\`name_chat\` 把聊天${previous ? ` \`${previous}\`` : ""}改名为 \`${r.name}\``, self, byHuman } : undefined);
|
|
558
631
|
if (!r.ok) {
|
|
559
632
|
json(res, 409, { ok: false, reason: r.reason });
|
|
560
633
|
return;
|
|
561
634
|
}
|
|
562
|
-
|
|
563
|
-
const home = baseOfKey(self);
|
|
564
|
-
const cur = wizards.get(home)?.name ?? "";
|
|
565
|
-
const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
|
|
566
|
-
if (wizardRenamed)
|
|
635
|
+
if (r.wizardRenamed)
|
|
567
636
|
void m.retitlePane(home);
|
|
568
|
-
json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
|
|
637
|
+
json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(r.wizardRenamed ? { wizardRenamed: r.wizardRenamed } : {}), ...(await charterOf(note)) });
|
|
569
638
|
});
|
|
570
639
|
const readPeerBody = async (req) => {
|
|
571
640
|
const body = (await readBody(req));
|
|
@@ -588,6 +657,10 @@ const main = async () => {
|
|
|
588
657
|
// 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
|
|
589
658
|
// 已挂过的 linkTags 认得出, 不会再套一层。
|
|
590
659
|
const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
|
|
660
|
+
// 出站媒体只经这一个口子 (通道无关, 见 media.ts); 别的通道接进来时在这里按 principal 分流。
|
|
661
|
+
// 绑定过的微信群聊走微信 (上限不同, 校验也跟着各通道走 —— planFor)。
|
|
662
|
+
const media = routeSendMedia(wx, weixinSendMedia(wx), aibotSendMedia(ws.client, log.child({ mod: "media" })));
|
|
663
|
+
const sendMedia = media.send;
|
|
591
664
|
const notifyChat = (base, markdown) => {
|
|
592
665
|
const chatId = chatIdOf(base);
|
|
593
666
|
void ws.client
|
|
@@ -595,28 +668,43 @@ const main = async () => {
|
|
|
595
668
|
.catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
|
|
596
669
|
};
|
|
597
670
|
// wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
|
|
598
|
-
// 那一段是路由信息 (`emoji
|
|
671
|
+
// 那一段是路由信息 (`emoji.name:`), parseTagHeader 靠它反解, 群里引用一条气泡
|
|
599
672
|
// 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
|
|
600
673
|
const displayName = (t) => {
|
|
601
674
|
const name = settleName(wizards, chatNameOf(cfg, t), t);
|
|
602
675
|
return name ? `.${name}` : "默认 wizard";
|
|
603
676
|
};
|
|
604
677
|
// wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
|
|
605
|
-
// (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a
|
|
678
|
+
// (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a: .b`, 方向一眼可读。
|
|
606
679
|
const RELAY_MAX = 1200;
|
|
607
|
-
/** 一个 wizard 在群里的称呼:
|
|
680
|
+
/** 一个 wizard 在群里的称呼: `.name`, 挂它的 rolepage。名字全局唯一, 不再
|
|
608
681
|
* 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
|
|
609
|
-
const relayLabel = (t
|
|
682
|
+
const relayLabel = (t) => ((h) => (h ? tagLink(t, h) : ""))(tagHead(t));
|
|
683
|
+
/** 以 `self` 的名义往 `dest` 群发一条: 头独占一行; 头怎么写 (群默认 wizard 报不报名) 归 ownHead。 */
|
|
684
|
+
const postAs = (self, dest, text) => notifyChat(dest, headed(ownHead(self, dest), text, "\n\n"));
|
|
685
|
+
// 没开顶层模式的群里公开气泡多了 → 往那个群的管家 (群默认会话, target 就是 base principal) 信箱挂一行,
|
|
686
|
+
// 让它问人要不要开顶层模式 (见 top-only-nudge.ts)。路径里写聊天名: 管家照抄就能 config_set。
|
|
687
|
+
const topOnlyNudge = createTopOnlyNudge({
|
|
688
|
+
store: loadJsonMap("~/.wezard/top-only-nudge.json", gcNudge),
|
|
689
|
+
policy: () => ((p) => ({ windowMs: p.windowMin * 60_000, threshold: p.threshold, cooldownMs: p.cooldownH * 3_600_000 }))(cfg.wrc.mirror.topOnlyNudge),
|
|
690
|
+
nudge: (chat, n) => {
|
|
691
|
+
const p = cfg.wrc.mirror.topOnlyNudge;
|
|
692
|
+
notices.post([chat], renderNudge(policyKeyOf(chatNameOf(cfg, chat), chat), n, p.windowMin, p.cooldownH));
|
|
693
|
+
log.info({ chat, n }, "top-only nudge: 已提醒管家问人要不要开顶层模式");
|
|
694
|
+
},
|
|
695
|
+
});
|
|
610
696
|
/** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
|
|
611
697
|
const relayPeer = (from, to, body, channel) => {
|
|
612
698
|
const text = body.trim();
|
|
613
699
|
if (!text || !channel)
|
|
614
700
|
return;
|
|
615
|
-
const head =
|
|
701
|
+
const head = speakerHead(relayLabel(from), relayLabel(to));
|
|
616
702
|
const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
|
|
617
703
|
// 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
|
|
618
704
|
// 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
|
|
619
705
|
notifyChat(channel, `${head}\n\n${clipped}`);
|
|
706
|
+
if (!chatPolicyOf(cfg, channel).topOnly)
|
|
707
|
+
topOnlyNudge.count(baseOfKey(channel));
|
|
620
708
|
};
|
|
621
709
|
/** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
|
|
622
710
|
* 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
|
|
@@ -633,7 +721,7 @@ const main = async () => {
|
|
|
633
721
|
* read_chat; 认不出 = ""。 */
|
|
634
722
|
const audienceName = (channel, asker) => {
|
|
635
723
|
const id = audienceOf(channel, asker);
|
|
636
|
-
return id
|
|
724
|
+
return id.startsWith("human:") ? id.slice("human:".length) : "";
|
|
637
725
|
};
|
|
638
726
|
// 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
|
|
639
727
|
const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
|
|
@@ -654,6 +742,12 @@ const main = async () => {
|
|
|
654
742
|
nameOf: displayName,
|
|
655
743
|
log,
|
|
656
744
|
store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
|
|
745
|
+
holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile),
|
|
746
|
+
// 放行停放着的 after 件: 走 tell_peer 同一条注入路径, 沿用停放时的件号 / 频道 / 父 k。
|
|
747
|
+
release: async (h, seen) => {
|
|
748
|
+
const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen), { turn: h.turn, channel: h.channel, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
|
|
749
|
+
return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
|
|
750
|
+
},
|
|
657
751
|
handingOff,
|
|
658
752
|
handedOff,
|
|
659
753
|
replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
|
|
@@ -873,6 +967,17 @@ const main = async () => {
|
|
|
873
967
|
// 给**人**看。收件人就是地址 —— 聊天名或裸 principal, 没有「先订阅才收得到」
|
|
874
968
|
// 这一步。头沿用 relay 那一套: 本群退化成寻常的 `emoji #tag`, 外群写成带聊天
|
|
875
969
|
// 名的全称, 于是那边的人一眼看得出是谁、从哪个群说过来的。
|
|
970
|
+
// 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
|
|
971
|
+
// 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
|
|
972
|
+
// 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
|
|
973
|
+
const topOnlyMuted = (self, dests) => {
|
|
974
|
+
const env = openingOfSelf(self)?.env;
|
|
975
|
+
const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
|
|
976
|
+
const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
|
|
977
|
+
return muted.length && belowTop
|
|
978
|
+
? { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` }
|
|
979
|
+
: undefined;
|
|
980
|
+
};
|
|
876
981
|
http.register("POST /notify", async (req, res) => {
|
|
877
982
|
const { self, body } = await readPeerBody(req);
|
|
878
983
|
if (!self) {
|
|
@@ -897,22 +1002,69 @@ const main = async () => {
|
|
|
897
1002
|
return;
|
|
898
1003
|
}
|
|
899
1004
|
const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const env = openingOfSelf(self)?.env;
|
|
904
|
-
const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
|
|
905
|
-
const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
|
|
906
|
-
if (muted.length && belowTop) {
|
|
907
|
-
json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
|
|
1005
|
+
const muted = topOnlyMuted(self, dests);
|
|
1006
|
+
if (muted) {
|
|
1007
|
+
json(res, 409, muted);
|
|
908
1008
|
return;
|
|
909
1009
|
}
|
|
910
1010
|
const asker = askerOf(self);
|
|
911
1011
|
for (const dest of dests) {
|
|
912
|
-
|
|
1012
|
+
postAs(self, dest, content);
|
|
913
1013
|
recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
|
|
914
1014
|
}
|
|
915
|
-
|
|
1015
|
+
// 微信群聊: 人超过 ctxTtlSec 没说话时微信会静默丢弃, 消息只能压着等他下次开口 —— 如实告诉调用方, 别回「已发送」。
|
|
1016
|
+
const wxId = (d) => baseOfKey(d).replace(/^chat:/, "");
|
|
1017
|
+
const held = dests.filter((d) => wx.owns(wxId(d)) && !wx.fresh(wxId(d)));
|
|
1018
|
+
const name = (d) => chatNameOf(cfg, d) || d;
|
|
1019
|
+
json(res, 200, {
|
|
1020
|
+
ok: true,
|
|
1021
|
+
sent: dests.filter((d) => !held.includes(d)).map(name),
|
|
1022
|
+
...(held.length ? { held: held.map(name), note: `微信群聊 ${held.map(name).join("、")} 那头超过 ${cfg.weixin.ctxTtlSec}s 没说话: 微信会静默丢弃主动推送, 这条已压着, 对方下次在微信里开口时送达 (定时提醒做不到准点)` } : {}),
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
// ── 给人看的文件 / 图片 ────────────────────────────────────────────
|
|
1026
|
+
// notify 的媒体版: 去向、认名字、顶层模式闸都与它同一套; 投递交给 sendMedia (通道无关,
|
|
1027
|
+
// 按 principal 分到 aibot / 微信)。caption 先成一条带头的气泡, 让群里知道是谁发的。
|
|
1028
|
+
http.register("POST /send_file", async (req, res) => {
|
|
1029
|
+
const { self, body } = await readPeerBody(req);
|
|
1030
|
+
if (!self) {
|
|
1031
|
+
json(res, 400, { ok: false, reason: "cannot resolve caller session" });
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const b = body;
|
|
1035
|
+
const path = expandHome((b.path ?? "").trim());
|
|
1036
|
+
if (!path) {
|
|
1037
|
+
json(res, 400, { ok: false, reason: "path required" });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
ensureChatNames(self);
|
|
1041
|
+
const ref = (b.to ?? "").trim();
|
|
1042
|
+
const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
|
|
1043
|
+
if (!dest) {
|
|
1044
|
+
json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
const muted = topOnlyMuted(self, [dest]);
|
|
1048
|
+
if (muted) {
|
|
1049
|
+
json(res, 409, muted);
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
const plan = media.plan(dest, { path, ...(b.kind ? { kind: b.kind } : {}) });
|
|
1053
|
+
if (!plan.ok) {
|
|
1054
|
+
json(res, 400, plan);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const caption = (b.caption ?? "").trim();
|
|
1058
|
+
if (caption)
|
|
1059
|
+
postAs(self, dest, caption);
|
|
1060
|
+
const r = await sendMedia(dest, { path, kind: plan.kind, ...(b.title ? { title: b.title } : {}), ...(b.description ? { description: b.description } : {}) });
|
|
1061
|
+
if (!r.ok) {
|
|
1062
|
+
json(res, 502, r);
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
const asker = askerOf(self);
|
|
1066
|
+
recordPost({ target: self, channel: dest, body: [caption, `📎 ${r.name} (${r.kind}) · ${path}`].filter(Boolean).join("\n\n"), ...(asker ? { asker } : {}) });
|
|
1067
|
+
json(res, 200, { ok: true, sent: chatNameOf(cfg, dest) || dest, kind: r.kind, name: r.name, bytes: r.bytes });
|
|
916
1068
|
});
|
|
917
1069
|
http.register("POST /peers/peek", async (req, res) => {
|
|
918
1070
|
const { self, body } = await readPeerBody(req);
|
|
@@ -1043,9 +1195,29 @@ const main = async () => {
|
|
|
1043
1195
|
const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
|
|
1044
1196
|
// 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
|
|
1045
1197
|
const coldRefused = new Map();
|
|
1198
|
+
/** 入参 `after` → 件号数组 (去反引号 / 空白 / 重复)。 */
|
|
1199
|
+
const afterTurnsOf = (body) => {
|
|
1200
|
+
const raw = body.after;
|
|
1201
|
+
return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
|
|
1202
|
+
};
|
|
1203
|
+
/** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
|
|
1204
|
+
const afterNote = (turns, seen) => renderReminder({ wezard: "after" }, [
|
|
1205
|
+
"这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
|
|
1206
|
+
...turns.flatMap((t) => {
|
|
1207
|
+
const x = seen[t];
|
|
1208
|
+
if (!x)
|
|
1209
|
+
return [];
|
|
1210
|
+
const c = parseClosing(x.body, 1500);
|
|
1211
|
+
const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
|
|
1212
|
+
return [
|
|
1213
|
+
`- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
|
|
1214
|
+
...c.artifacts.map((a) => ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`),
|
|
1215
|
+
];
|
|
1216
|
+
}),
|
|
1217
|
+
]);
|
|
1046
1218
|
/** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
|
|
1047
1219
|
* 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
|
|
1048
|
-
const tellPeerAs = async (self, body, extra = "") => {
|
|
1220
|
+
const tellPeerAs = async (self, body, extra = "", held) => {
|
|
1049
1221
|
const text = (body.text ?? "").toString();
|
|
1050
1222
|
if (!text.trim()) {
|
|
1051
1223
|
return { status: 400, body: { ok: false, reason: "text required" } };
|
|
@@ -1061,7 +1233,8 @@ const main = async () => {
|
|
|
1061
1233
|
const { target, foreign } = r;
|
|
1062
1234
|
// 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
|
|
1063
1235
|
const re = (body.re ?? "").toString().trim();
|
|
1064
|
-
|
|
1236
|
+
// 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
|
|
1237
|
+
const turn = held ? { turn: held.turn, legs: 1, channel: held.channel } : receipts.prepare(self, target, re || undefined);
|
|
1065
1238
|
// 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
|
|
1066
1239
|
// 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
|
|
1067
1240
|
// 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
|
|
@@ -1075,11 +1248,40 @@ const main = async () => {
|
|
|
1075
1248
|
const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
|
|
1076
1249
|
const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
|
|
1077
1250
|
if (jobId) {
|
|
1078
|
-
const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
|
|
1251
|
+
const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
|
|
1079
1252
|
if (!jc.ok) {
|
|
1080
1253
|
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
1081
1254
|
}
|
|
1082
1255
|
}
|
|
1256
|
+
// 派活方向闸 (见 direction.ts): 只拒不改投递路径, 不动回执 k。
|
|
1257
|
+
const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
|
|
1258
|
+
if (dr) {
|
|
1259
|
+
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
1260
|
+
}
|
|
1261
|
+
// 依赖 (after): 前置必须是自己派出去的件。查不到 → 400; 有失败的 → 409; 都 done → 照常投并附结论; 还有没落定的 → 停放 (见下)。
|
|
1262
|
+
const afters = held ? [] : afterTurnsOf(body);
|
|
1263
|
+
const prereqs = afters.length ? receipts.prereqs(self, afters) : [];
|
|
1264
|
+
if (afters.length) {
|
|
1265
|
+
if (kind !== "task" || re) {
|
|
1266
|
+
return { status: 400, body: { ok: false, reason: "`after` 只用于派新活 (kind:\"task\"、不带 re)" } };
|
|
1267
|
+
}
|
|
1268
|
+
const gone = afters.filter((_, i) => prereqs[i].state === "unknown");
|
|
1269
|
+
if (gone.length) {
|
|
1270
|
+
return { status: 400, body: { ok: false, reason: `after 里的 ${gone.join(" ")} 在你的回执登记里查不到 —— 只能依赖你自己派出去的件号 (过期 / 被同一对的新一句顶掉的也查不到)` } };
|
|
1271
|
+
}
|
|
1272
|
+
const failed = afters.flatMap((t, i) => { const p = prereqs[i]; return p.state === "failed" ? [`${t} (${displayName(p.seen.to)}) 以 ${p.seen.status} 收场`] : []; });
|
|
1273
|
+
if (failed.length) {
|
|
1274
|
+
return { status: 409, body: { ok: false, reason: `前置 ${failed.join("; ")} —— 这件不投; 前置失败了就先处理它, 再重发` } };
|
|
1275
|
+
}
|
|
1276
|
+
const cur = receipts.pending(self, target);
|
|
1277
|
+
if (cur && !afters.includes(cur.turn)) {
|
|
1278
|
+
return { status: 409, body: { ok: false, reason: `你已有一件活 (${cur.turn}) 在 ${displayName(target)} 手上没落定, 同一对只有一个回执槽 —— 等它交差, 或把它也列进 after` } };
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
const parkIt = prereqs.some((p) => p.state === "pending");
|
|
1282
|
+
// 前置都已落定: 不停放, 结论附在信封后面照常投。
|
|
1283
|
+
if (afters.length && !parkIt)
|
|
1284
|
+
extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])));
|
|
1083
1285
|
// 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
|
|
1084
1286
|
// 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
|
|
1085
1287
|
// 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
|
|
@@ -1131,6 +1333,25 @@ const main = async () => {
|
|
|
1131
1333
|
return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
|
|
1132
1334
|
}
|
|
1133
1335
|
}
|
|
1336
|
+
// 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
|
|
1337
|
+
// 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
|
|
1338
|
+
if (parkIt) {
|
|
1339
|
+
const bp = body;
|
|
1340
|
+
const pk = bp.chain === false ? undefined : parentKOf(self);
|
|
1341
|
+
const pasker = askerOf(self);
|
|
1342
|
+
receipts.hold({
|
|
1343
|
+
from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
|
|
1344
|
+
...(pk ? { k: pk } : {}), ...(pasker ? { asker: pasker } : {}),
|
|
1345
|
+
// force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
|
|
1346
|
+
body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
|
|
1347
|
+
});
|
|
1348
|
+
if (jobId) {
|
|
1349
|
+
const role = roleParam(bp.role);
|
|
1350
|
+
jobs.attach(jobId, { target, task: text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1351
|
+
jobs.spend(jobId);
|
|
1352
|
+
}
|
|
1353
|
+
return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置都 done 才投 (带上它们的结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
|
|
1354
|
+
}
|
|
1134
1355
|
// 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
|
|
1135
1356
|
const wasBusy = !(await m.idleNow(target));
|
|
1136
1357
|
// urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
|
|
@@ -1169,7 +1390,7 @@ const main = async () => {
|
|
|
1169
1390
|
// 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
|
|
1170
1391
|
const wantReceipt = kind !== "fyi" && body.receipt !== false;
|
|
1171
1392
|
const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
|
|
1172
|
-
const asker = askerOf(self);
|
|
1393
|
+
const asker = held ? held.asker : askerOf(self);
|
|
1173
1394
|
const inj = await m.injectText(target, text, undefined, {
|
|
1174
1395
|
from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
|
|
1175
1396
|
channel,
|
|
@@ -1187,7 +1408,7 @@ const main = async () => {
|
|
|
1187
1408
|
// 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
|
|
1188
1409
|
// 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
|
|
1189
1410
|
// 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
|
|
1190
|
-
const k = body.chain === false ? undefined : parentKOf(self);
|
|
1411
|
+
const k = held ? held.k : body.chain === false ? undefined : parentKOf(self);
|
|
1191
1412
|
if (inj.ok && wantReceipt)
|
|
1192
1413
|
receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
|
|
1193
1414
|
if (inj.ok && wantReceipt) {
|
|
@@ -1206,7 +1427,8 @@ const main = async () => {
|
|
|
1206
1427
|
jobs.mark(jobId, { gate: { g2: Date.now() } });
|
|
1207
1428
|
jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
|
|
1208
1429
|
}
|
|
1209
|
-
|
|
1430
|
+
// 停放时已记过预算, 放行不再算一次。
|
|
1431
|
+
const spent = inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
|
|
1210
1432
|
if (inj.ok && isPublic)
|
|
1211
1433
|
relayPeer(self, target, text, channel);
|
|
1212
1434
|
// `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
|
|
@@ -1321,10 +1543,12 @@ const main = async () => {
|
|
|
1321
1543
|
});
|
|
1322
1544
|
// 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
|
|
1323
1545
|
// /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
|
|
1324
|
-
// 还得靠 sid
|
|
1546
|
+
// 还得靠 sid 轮换探测才跟得上。CLI / cwd / keepalive 照旧沿用; 模型也沿用, 管家除外 ——
|
|
1547
|
+
// 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
|
|
1325
1548
|
const restartFresh = (target) => {
|
|
1326
1549
|
const info = m.sessionInfo(target);
|
|
1327
|
-
|
|
1550
|
+
const model = stewardTierOf(cfg, target) ? undefined : info?.model || undefined;
|
|
1551
|
+
return m.newSession(target, displayName(target) || tagOfKey(target) || target, info?.cli, { model, silent: true, warm: true });
|
|
1328
1552
|
};
|
|
1329
1553
|
// POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
|
|
1330
1554
|
// 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
|
|
@@ -1474,6 +1698,19 @@ const main = async () => {
|
|
|
1474
1698
|
const notices = bindNoticeBox(createNoticeBox({
|
|
1475
1699
|
probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
|
|
1476
1700
|
digest: pendingDigest,
|
|
1701
|
+
// 群况快照只给长住的 (没有 parent): 分身的协调人是生它的那位, 宪章里的出生快照够用。
|
|
1702
|
+
// 表里是这个群的常驻面: 管家、长住有职责的、管家生的 (群里的执行者)、收件人自己生的;
|
|
1703
|
+
// 再往下一层的 (分身的分身, 回归测试那批 rr-*) 不进表 —— 同名册变动 hears 的取舍。
|
|
1704
|
+
snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => m.chatTargets(t)
|
|
1705
|
+
.filter((x) => x !== t && wizards.get(x)?.name)
|
|
1706
|
+
.filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
|
|
1707
|
+
.map((x) => {
|
|
1708
|
+
const i = m.sessionInfo(x);
|
|
1709
|
+
const state = ((v) => (v === undefined ? "-" : v ? "idle" : m.parkedNow(x) ? "parked" : "busy"))(m.idleVerdict(x));
|
|
1710
|
+
// 冷热与 route_candidates / tell_peer 冷门控同一口径 (touchOf / ttlOf)。
|
|
1711
|
+
const warmth = i?.jsonlPath ? { ctx: i.contextTokens, cold: wakeCostOf(i.contextTokens, touchOf(i.jsonlPath), Date.now(), ttlOf(i.jsonlPath)).cold } : {};
|
|
1712
|
+
return { name: displayName(x), state, ...warmth, job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward") };
|
|
1713
|
+
})),
|
|
1477
1714
|
seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
|
|
1478
1715
|
cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
|
|
1479
1716
|
}));
|
|
@@ -1501,7 +1738,7 @@ const main = async () => {
|
|
|
1501
1738
|
const one = xs.length === 1;
|
|
1502
1739
|
const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
|
|
1503
1740
|
const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
|
|
1504
|
-
|
|
1741
|
+
postAs(owner, chat, text);
|
|
1505
1742
|
recordPost({ target: owner, channel: chat, body: text });
|
|
1506
1743
|
xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
|
|
1507
1744
|
notices.post([owner], `nudged the human (daemon, in your name) about finished requests; their next line may be the verdict on one: ${xs.map(({ job }) => `${job.id} "${job.title}"`).join(" · ")}`);
|
|
@@ -1548,7 +1785,9 @@ const main = async () => {
|
|
|
1548
1785
|
.slice()
|
|
1549
1786
|
.sort((x, y) => y.targets.length - x.targets.length)
|
|
1550
1787
|
.map((c) => ({ base: c.base, cwd: chatCwd(c.targets) })));
|
|
1551
|
-
|
|
1788
|
+
if (!Object.keys(plan).length)
|
|
1789
|
+
return {};
|
|
1790
|
+
const { r: named } = guardCharter(() => applyChatNames(cfg, sourcePath, plan), (n) => Object.keys(n).length ? { what: `自动补的聊天名 (${Object.values(n).map((x) => `\`${x}\``).join(" ")})` } : undefined);
|
|
1552
1791
|
for (const [base, name] of Object.entries(named)) {
|
|
1553
1792
|
log.info({ base, name }, "chat: auto-named from workspace");
|
|
1554
1793
|
// 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
|
|
@@ -1702,6 +1941,12 @@ const main = async () => {
|
|
|
1702
1941
|
};
|
|
1703
1942
|
}),
|
|
1704
1943
|
chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
|
|
1944
|
+
// 微信用户的发话人 id 就是它那个群的 chatId: 名字跟着群名走。
|
|
1945
|
+
humanNames: Object.fromEntries(wx.list().map((a) => [`human:${a.chatId}`, chatNameOf(cfg, `chat:${a.chatId}`)]).filter(([, n]) => n)),
|
|
1946
|
+
weixin: wx.list().map((a) => ({
|
|
1947
|
+
base: `chat:${a.chatId}`, state: a.state, lastInAt: a.lastInAt ?? 0, held: a.outbox.length,
|
|
1948
|
+
budget: a.ctx ? Math.max(0, cfg.weixin.sendCap - a.ctx.used) : 0, pausedUntil: a.pausedUntil ?? 0,
|
|
1949
|
+
})),
|
|
1705
1950
|
};
|
|
1706
1951
|
};
|
|
1707
1952
|
// 一次采集失败 (某个 pane 的 tmux 抖了一下) 不等于注册表没了: 退回上一份好的,
|
|
@@ -1721,6 +1966,7 @@ const main = async () => {
|
|
|
1721
1966
|
name: settleName(wizards, chatNameOf(cfg, target), target),
|
|
1722
1967
|
address: peerAddress(cfg, self, target),
|
|
1723
1968
|
description: wizards.get(target)?.description ?? "",
|
|
1969
|
+
...(wizards.get(target)?.expertise ? { expertise: wizards.get(target).expertise } : {}),
|
|
1724
1970
|
cwd: m.getCwd(target).runningCwd,
|
|
1725
1971
|
});
|
|
1726
1972
|
/** 自己的地址 = 全局名字, 与住在哪个群无关。 */
|
|
@@ -1939,6 +2185,7 @@ const main = async () => {
|
|
|
1939
2185
|
const b = body;
|
|
1940
2186
|
const asked = normalizeTag((b.name ?? "").toString());
|
|
1941
2187
|
const description = (b.description ?? "").toString().trim();
|
|
2188
|
+
const expertise = (b.expertise ?? "").toString().trim();
|
|
1942
2189
|
if ((b.name ?? "").toString().trim() && !asked) {
|
|
1943
2190
|
json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
|
|
1944
2191
|
return;
|
|
@@ -1947,6 +2194,9 @@ const main = async () => {
|
|
|
1947
2194
|
const name = asked ? wizards.rename(self, asked) : before;
|
|
1948
2195
|
if (description)
|
|
1949
2196
|
wizards.upsert(self, { description });
|
|
2197
|
+
// 专长: 传 "-" 清掉; 它不进名册变动通知 (只在名册 / 候选行里读)。
|
|
2198
|
+
if (expertise)
|
|
2199
|
+
wizards.upsert(self, { expertise: expertise === "-" ? undefined : expertise });
|
|
1950
2200
|
const me = identityOf(self, self);
|
|
1951
2201
|
// 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
|
|
1952
2202
|
// 出生快照里那句旧的 (或者空的) 职责派活。
|
|
@@ -1968,7 +2218,7 @@ const main = async () => {
|
|
|
1968
2218
|
const q = (b.query ?? "").trim().toLowerCase();
|
|
1969
2219
|
const chat = (b.chat ?? "").trim().toLowerCase();
|
|
1970
2220
|
const cwd = (b.cwd ?? "").trim().toLowerCase();
|
|
1971
|
-
return (w) => (!q || [w.name, w.address, w.description, w.target].some((f) => (f ?? "").toLowerCase().includes(q))) &&
|
|
2221
|
+
return (w) => (!q || [w.name, w.address, w.description, w.expertise, w.target].some((f) => (f ?? "").toLowerCase().includes(q))) &&
|
|
1972
2222
|
(!chat || (w.chat ?? "").toLowerCase() === chat || (w.target ?? "").toLowerCase().includes(chat)) &&
|
|
1973
2223
|
(!cwd || (w.cwd ?? "").toLowerCase().includes(cwd)) &&
|
|
1974
2224
|
(b.alive !== true || w.alive);
|
|
@@ -1994,7 +2244,7 @@ const main = async () => {
|
|
|
1994
2244
|
const shown = ranked.slice(0, limit).map((r) => ({
|
|
1995
2245
|
...r,
|
|
1996
2246
|
turns: turnsLine(inflight, r.target),
|
|
1997
|
-
duty: dutyLine(jobs.all(), r.target),
|
|
2247
|
+
duty: dutyLine(jobs.all(), r.target, (t) => `.${displayName(t)}`),
|
|
1998
2248
|
model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
|
|
1999
2249
|
summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
|
|
2000
2250
|
contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
|
|
@@ -2048,7 +2298,7 @@ const main = async () => {
|
|
|
2048
2298
|
return;
|
|
2049
2299
|
}
|
|
2050
2300
|
const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
|
|
2051
|
-
const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
|
|
2301
|
+
const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self), { now: Date.now(), ttlMs: cfg.wrc.mirror.keepalive.ttlSec * 1000 }).slice(0, limit);
|
|
2052
2302
|
json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
|
|
2053
2303
|
});
|
|
2054
2304
|
// 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
|
|
@@ -2075,7 +2325,7 @@ const main = async () => {
|
|
|
2075
2325
|
// 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
|
|
2076
2326
|
const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
|
|
2077
2327
|
const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
|
|
2078
|
-
const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
|
|
2328
|
+
const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool, { now, ttlMs }).slice(0, 5);
|
|
2079
2329
|
let d = to ? { kind: "existing", why: `你点名了 ${to}` }
|
|
2080
2330
|
: b.spawn ? { kind: "spawn", why: "你要了新的" }
|
|
2081
2331
|
: lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
|
|
@@ -2106,7 +2356,7 @@ const main = async () => {
|
|
|
2106
2356
|
const settleRoot = (ok) => { if (rootId && !ok)
|
|
2107
2357
|
jobs.close(rootId, "派活没投成"); };
|
|
2108
2358
|
const withRoot = (body) => rootId
|
|
2109
|
-
? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job)
|
|
2359
|
+
? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
|
|
2110
2360
|
: body;
|
|
2111
2361
|
if (d.kind === "existing") {
|
|
2112
2362
|
const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
|
|
@@ -2123,13 +2373,13 @@ const main = async () => {
|
|
|
2123
2373
|
wizards.upsert(r.target, { lead: true });
|
|
2124
2374
|
if (rootId && t.status === 200)
|
|
2125
2375
|
jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
|
|
2126
|
-
//
|
|
2127
|
-
if (!(t.body.gated === "cold"
|
|
2376
|
+
// 自动选中的被冷门控 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点) 或方向闸 (它归别人管 / 是借来的专家) 退回: 改走 spawn; 点名的不改。
|
|
2377
|
+
if (!(!to && (t.body.gated === "cold" || t.body.gated === "direction"))) {
|
|
2128
2378
|
settleRoot(t.status === 200);
|
|
2129
2379
|
json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
|
|
2130
2380
|
return;
|
|
2131
2381
|
}
|
|
2132
|
-
d = { kind: "spawn", why: `${d.why};
|
|
2382
|
+
d = { kind: "spawn", why: `${d.why}; 但它被${t.body.gated === "direction" ? "方向闸" : "冷门控"}退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
|
|
2133
2383
|
}
|
|
2134
2384
|
const name = (b.name ?? "").toString().trim();
|
|
2135
2385
|
const description = (b.description ?? "").toString().trim();
|
|
@@ -2148,7 +2398,7 @@ const main = async () => {
|
|
|
2148
2398
|
// 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
|
|
2149
2399
|
const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
|
|
2150
2400
|
settleRoot(t.status === 200);
|
|
2151
|
-
//
|
|
2401
|
+
// 为这张根单生出来的 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
|
|
2152
2402
|
if (rootId && t.status === 200)
|
|
2153
2403
|
jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
|
|
2154
2404
|
json(res, t.status, withRoot({
|
|
@@ -2243,6 +2493,11 @@ const main = async () => {
|
|
|
2243
2493
|
return { status: jc.status, body: { ok: false, reason: jc.reason } };
|
|
2244
2494
|
}
|
|
2245
2495
|
}
|
|
2496
|
+
// 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
|
|
2497
|
+
const dr = (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
|
|
2498
|
+
if (dr) {
|
|
2499
|
+
return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
|
|
2500
|
+
}
|
|
2246
2501
|
// `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
|
|
2247
2502
|
// 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
|
|
2248
2503
|
// 逼调用方每次自己说。
|
|
@@ -2275,16 +2530,28 @@ const main = async () => {
|
|
|
2275
2530
|
return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
|
|
2276
2531
|
}
|
|
2277
2532
|
const alivePeers = detached ? [] : await m.peers(self);
|
|
2278
|
-
//
|
|
2279
|
-
//
|
|
2533
|
+
// 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
|
|
2534
|
+
// (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
|
|
2535
|
+
// 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
|
|
2280
2536
|
// 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
|
|
2281
|
-
const aliveKids = childrenOf(wizards.all(), self).
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2537
|
+
const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
|
|
2538
|
+
const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
|
|
2539
|
+
if (over > 0) {
|
|
2540
|
+
const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
|
|
2541
|
+
const owing = new Set(receipts.states().map((x) => x.to));
|
|
2542
|
+
const idle = aliveKids
|
|
2543
|
+
.filter((pp) => !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
|
|
2544
|
+
.sort((x, y) => x.lastActivity - y.lastActivity)
|
|
2545
|
+
.slice(0, over);
|
|
2546
|
+
const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
|
|
2547
|
+
if (slept < over) {
|
|
2548
|
+
return { status: 429, body: {
|
|
2549
|
+
ok: false,
|
|
2550
|
+
reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
|
|
2551
|
+
clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
|
|
2552
|
+
} };
|
|
2553
|
+
}
|
|
2554
|
+
log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
|
|
2288
2555
|
}
|
|
2289
2556
|
const wantChat = (b.chat ?? "").toString().trim();
|
|
2290
2557
|
const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
|
|
@@ -2598,7 +2865,7 @@ const main = async () => {
|
|
|
2598
2865
|
title,
|
|
2599
2866
|
...(parentJob ? { parent: parentJob.id } : {}),
|
|
2600
2867
|
memberMax: JOB_MEMBER_MAX,
|
|
2601
|
-
hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job
|
|
2868
|
+
hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档, 给人的结论写在你自己这一轮的最终回复里。",
|
|
2602
2869
|
});
|
|
2603
2870
|
});
|
|
2604
2871
|
http.register("POST /jobs/close", async (req, res) => {
|
|
@@ -2639,22 +2906,18 @@ const main = async () => {
|
|
|
2639
2906
|
json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
|
|
2640
2907
|
return;
|
|
2641
2908
|
}
|
|
2642
|
-
// 关一张单连它还开着的后代单一起关 (深的先关):
|
|
2643
|
-
//
|
|
2644
|
-
//
|
|
2645
|
-
const recycle = b.stop !== false;
|
|
2909
|
+
// 关一张单连它还开着的后代单一起关 (深的先关): 否则账本上挂着没人收的子单。
|
|
2910
|
+
// 收工只结账、留档, 不碰成员的 pane: 分身的去留与工单无关, pane 多了由 maxPanes / cloneMax 自己收
|
|
2911
|
+
// (只关 pane、绑定留着)。老 MCP 进程还会带 `stop`, 收下不用。
|
|
2646
2912
|
const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
|
|
2647
2913
|
const cascade = openKids(job);
|
|
2648
2914
|
const closeOne = async (j, summary, mark) => {
|
|
2649
|
-
const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
|
|
2650
|
-
// 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
|
|
2651
|
-
victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
|
|
2652
|
-
// 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
|
|
2653
2915
|
const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
|
|
2654
|
-
|
|
2655
|
-
// 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
|
|
2916
|
+
// 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
|
|
2656
2917
|
if (how === "cancel")
|
|
2657
|
-
j.members.filter((mm) => !mm.outcome
|
|
2918
|
+
j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
|
|
2919
|
+
// 这张单名下还排着没投的 (after 停放) 也不投了。
|
|
2920
|
+
receipts.cancelHeld(j.id, j.owner);
|
|
2658
2921
|
const closed = jobs.close(j.id, summary, mark);
|
|
2659
2922
|
ledger.closeJob(j.id);
|
|
2660
2923
|
// 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
|
|
@@ -2663,13 +2926,12 @@ const main = async () => {
|
|
|
2663
2926
|
if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
|
|
2664
2927
|
log.warn({ job: j.id, name: ep.name }, "job episode not archived");
|
|
2665
2928
|
}
|
|
2666
|
-
return { members: closed.members.length
|
|
2929
|
+
return { members: closed.members.length };
|
|
2667
2930
|
};
|
|
2668
2931
|
const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
|
|
2669
2932
|
// 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
|
|
2670
2933
|
.reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
|
|
2671
|
-
|
|
2672
|
-
json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
|
|
2934
|
+
json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
|
|
2673
2935
|
});
|
|
2674
2936
|
// 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
|
|
2675
2937
|
// `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
|
|
@@ -2861,7 +3123,27 @@ const main = async () => {
|
|
|
2861
3123
|
usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
|
|
2862
3124
|
return usageMemo.u;
|
|
2863
3125
|
};
|
|
2864
|
-
|
|
3126
|
+
/** chatPolicy 的依据: 各聊天名 ↔ principal 与它此刻的顶层模式 —— 键落盘是 principal, 人和模型认的是名字。 */
|
|
3127
|
+
const chatPolicyEvidence = (p) => {
|
|
3128
|
+
if (p[0] !== "chatPolicy")
|
|
3129
|
+
return [];
|
|
3130
|
+
const named = listChatNames(cfg);
|
|
3131
|
+
const line = (b) => `${named.filter((c) => c.base === b).map((c) => c.name).join(" / ") || "(未命名)"} = ${b} · 顶层模式 ${chatPolicyOf(cfg, b).topOnly ? "开" : "关"}`;
|
|
3132
|
+
// 查某一个聊天: 只报它; 查整张表: 报写过策略的, 其余命名聊天只报个数。
|
|
3133
|
+
if (p[1])
|
|
3134
|
+
return [line(p[1])];
|
|
3135
|
+
const set = Object.keys(cfg.chatPolicy);
|
|
3136
|
+
const rest = named.filter((c) => !set.includes(c.base)).length;
|
|
3137
|
+
return [
|
|
3138
|
+
"键写聊天名或 principal 都行, 落盘换成 principal:",
|
|
3139
|
+
...set.map(line),
|
|
3140
|
+
...(rest ? [`其余 ${rest} 个已命名聊天没写, 按默认 (顶层模式关) —— 查某个: config_get({path:"chatPolicy.<聊天名>"})`] : []),
|
|
3141
|
+
];
|
|
3142
|
+
};
|
|
3143
|
+
const configEvidence = (p) => [
|
|
3144
|
+
...tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
|
|
3145
|
+
...chatPolicyEvidence(p),
|
|
3146
|
+
];
|
|
2865
3147
|
// ── 改配置会不会动到宪章 ─────────────────────────────────────────
|
|
2866
3148
|
// 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
|
|
2867
3149
|
// 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
|
|
@@ -2871,17 +3153,19 @@ const main = async () => {
|
|
|
2871
3153
|
const x = charterSections(a), y = charterSections(b);
|
|
2872
3154
|
return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
|
|
2873
3155
|
};
|
|
2874
|
-
/** pane
|
|
2875
|
-
const
|
|
3156
|
+
/** 宪章挂在 pane 上的 wizard —— 候选。同步取: 守卫要在写之前拍快照, 中间不能 await。 */
|
|
3157
|
+
const attachedWizards = () => wizards.all().map((w) => w.target).filter((t) => !!m.sessionInfo(t)?.tmuxPane);
|
|
3158
|
+
/** 其中 pane 活着的 —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
|
|
3159
|
+
const liveOf = async (targets) => {
|
|
2876
3160
|
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
|
|
2877
3161
|
const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
|
|
2878
|
-
return
|
|
3162
|
+
return targets.filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
|
|
2879
3163
|
};
|
|
2880
3164
|
/** 这些 wizard 此刻的宪章正文。 */
|
|
2881
3165
|
const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
|
|
2882
3166
|
/** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
|
|
2883
3167
|
* reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
|
|
2884
|
-
const charterUnder = (pl
|
|
3168
|
+
const charterUnder = (pl) => (targets) => {
|
|
2885
3169
|
const live = cfg;
|
|
2886
3170
|
const key = pl.path[0];
|
|
2887
3171
|
const saved = live[key];
|
|
@@ -2893,26 +3177,48 @@ const main = async () => {
|
|
|
2893
3177
|
live[key] = saved;
|
|
2894
3178
|
}
|
|
2895
3179
|
};
|
|
2896
|
-
const charterImpact = (before,
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
/** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。 */
|
|
2901
|
-
const announceCharterImpact = (pl, hits, self) => {
|
|
3180
|
+
const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
|
|
3181
|
+
/** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。
|
|
3182
|
+
* 人发起的改动多一句「交接之前还是旧规矩」: 管家要知道它此刻向人汇报的方式还没变, 别让人以为已经生效。 */
|
|
3183
|
+
const announceCharterImpact = (c, hits) => {
|
|
2902
3184
|
if (!hits.length)
|
|
2903
3185
|
return "";
|
|
2904
|
-
const
|
|
2905
|
-
const
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
const
|
|
3186
|
+
const ask = `${c.reload ? "等守护进程 reload 之后再" : ""}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
|
|
3187
|
+
const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
|
|
3188
|
+
? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
|
|
3189
|
+
: " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
|
|
3190
|
+
const others = hits.filter((h) => h.target !== c.self);
|
|
3191
|
+
others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
|
|
3192
|
+
const mine = hits.find((h) => h.target === c.self);
|
|
2909
3193
|
return [
|
|
2910
3194
|
"",
|
|
2911
3195
|
`⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
|
|
2912
3196
|
...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
|
|
2913
|
-
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}`] : []),
|
|
3197
|
+
...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
|
|
2914
3198
|
].join("\n");
|
|
2915
3199
|
};
|
|
3200
|
+
// 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
|
|
3201
|
+
// 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒。快照到写完之间不能有 await —— 别人的写入
|
|
3202
|
+
// 插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
|
|
3203
|
+
guardCharter = (write, change) => {
|
|
3204
|
+
const targets = attachedWizards();
|
|
3205
|
+
const before = charterSnapshot(targets);
|
|
3206
|
+
const r = write();
|
|
3207
|
+
const c = change(r);
|
|
3208
|
+
if (!c)
|
|
3209
|
+
return { r, note: Promise.resolve("") };
|
|
3210
|
+
const fail = (e) => {
|
|
3211
|
+
log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
|
|
3212
|
+
return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
|
|
3213
|
+
};
|
|
3214
|
+
try {
|
|
3215
|
+
const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
|
|
3216
|
+
return { r, note: liveOf(hits.map((h) => h.target)).then((live) => announceCharterImpact(c, hits.filter((h) => live.includes(h.target)))).catch(fail) };
|
|
3217
|
+
}
|
|
3218
|
+
catch (e) {
|
|
3219
|
+
return { r, note: Promise.resolve(fail(e)) };
|
|
3220
|
+
}
|
|
3221
|
+
};
|
|
2916
3222
|
// POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
|
|
2917
3223
|
http.register("POST /config/get", async (req, res) => {
|
|
2918
3224
|
const body = (await readBody(req));
|
|
@@ -2921,7 +3227,10 @@ const main = async () => {
|
|
|
2921
3227
|
json(res, r.ok ? 200 : 400, r);
|
|
2922
3228
|
return;
|
|
2923
3229
|
}
|
|
2924
|
-
|
|
3230
|
+
// chatPolicy.<聊天名>… 换成它指向的 principal 再查 (同 config_set 的 pinChatKeys); 认不出的原样查。
|
|
3231
|
+
const p = parsePath(body.path);
|
|
3232
|
+
const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(cfg, p[1]) : "";
|
|
3233
|
+
const r = configGet(cfg, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
|
|
2925
3234
|
json(res, r.ok ? 200 : 400, r);
|
|
2926
3235
|
});
|
|
2927
3236
|
// chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
|
|
@@ -2974,26 +3283,21 @@ const main = async () => {
|
|
|
2974
3283
|
return;
|
|
2975
3284
|
}
|
|
2976
3285
|
// 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
|
|
2977
|
-
//
|
|
3286
|
+
// 宪章快照取在落盘之前 (守卫里): hot 项一 applyHot, 活的 cfg 就已经是新的了。
|
|
2978
3287
|
// 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
|
|
2979
3288
|
const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
|
|
3289
|
+
// 人发起 = 调用方这一轮的链头是人 (人在群里 / 单聊里叫它改的)。
|
|
3290
|
+
const byHuman = !!caller && !!askerOf(caller)?.who.startsWith("user:");
|
|
2980
3291
|
const commit = async () => {
|
|
2981
|
-
const
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
3292
|
+
const { r: now, note } = guardCharter(() => {
|
|
3293
|
+
const now = planSet(cfg, sourcePath, ask);
|
|
3294
|
+
if (!now.ok)
|
|
3295
|
+
return now;
|
|
3296
|
+
patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
|
|
3297
|
+
applyHot(cfg, now);
|
|
2985
3298
|
return now;
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
applyHot(cfg, now);
|
|
2989
|
-
// 已经落盘了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
|
|
2990
|
-
try {
|
|
2991
|
-
return { ...now, charter: announceCharterImpact(now, charterImpact(before, now), caller) };
|
|
2992
|
-
}
|
|
2993
|
-
catch (e) {
|
|
2994
|
-
log.warn({ err: errText(e), path: now.path.join(".") }, "config_set: 宪章影响比对失败");
|
|
2995
|
-
return { ...now, charter: `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的配置, 受影响的 wizard 要 handoff 才换上)` };
|
|
2996
|
-
}
|
|
3299
|
+
}, (now) => now.ok ? { what: `\`config_set ${now.path.join(".")}\``, self: caller, byHuman, reload: now.apply === "reload", after: charterUnder(now) } : undefined);
|
|
3300
|
+
return now.ok ? { ...now, charter: await note } : now;
|
|
2997
3301
|
};
|
|
2998
3302
|
const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
|
|
2999
3303
|
if (pl.gate !== "card") {
|
|
@@ -3194,7 +3498,7 @@ const main = async () => {
|
|
|
3194
3498
|
const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
|
|
3195
3499
|
if (reply && (await m.idleNow(t))) {
|
|
3196
3500
|
if (!isQuiet(reply))
|
|
3197
|
-
notifyChat(base, withTagHeader(t, reply));
|
|
3501
|
+
notifyChat(base, withTagHeader(t, reply, undefined, base));
|
|
3198
3502
|
log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
|
|
3199
3503
|
quietRelays.drop(turn);
|
|
3200
3504
|
return;
|
|
@@ -3224,6 +3528,7 @@ const main = async () => {
|
|
|
3224
3528
|
wizards.upsert(runner, {
|
|
3225
3529
|
description: `定时任务 ${taskId} 的一次性执行体${why}`,
|
|
3226
3530
|
parent: owner,
|
|
3531
|
+
voiceOf: owner,
|
|
3227
3532
|
bornAt: Date.now(),
|
|
3228
3533
|
});
|
|
3229
3534
|
const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
|
|
@@ -3366,6 +3671,7 @@ const main = async () => {
|
|
|
3366
3671
|
steward?.stop();
|
|
3367
3672
|
tasks.stop();
|
|
3368
3673
|
netWatch.stop();
|
|
3674
|
+
await wx.stop().catch(() => undefined);
|
|
3369
3675
|
// 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
|
|
3370
3676
|
log.info(drainForReload(), "pending drained for reload");
|
|
3371
3677
|
// Hard-exit watchdog: http.close() blocks until every in-flight connection
|