wezard 2.1.0 → 2.1.2

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.
@@ -29,12 +29,12 @@ import { selectModel } from "./model-select.js";
29
29
  import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince } from "./cc-session.js";
30
30
  import { wizardStore } from "./wizard.js";
31
31
  import { startSubagentWatch } from "./subagent-tail.js";
32
- import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
32
+ import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
33
33
  import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
34
34
  import { splitMarkdown } from "../shared/md-chunk.js";
35
35
  import { randomTip } from "./tips.js";
36
36
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
37
- import { stripAnsi, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, talkRounds, openToolUses, renderDialog } from "./peers.js";
37
+ import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, keepalivePingSigs, isKeepalivePingText, talkRounds, openToolUses, renderDialog } from "./peers.js";
38
38
  // PATH augmentation: launchd / systemd start the daemon
39
39
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
40
40
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -686,8 +686,9 @@ const renderLine = (raw, deps) => {
686
686
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
687
687
  // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line and
688
688
  // the chat-detail links linkTags hangs on it and on up to MAX_BODY_LINKS body
689
- // mentions — each `(url)` runs ~200-250 bytes.
690
- const TAG_HEADER_BUDGET = 256 * (1 + MAX_BODY_LINKS);
689
+ // mentions — each `(url)` runs ~200-250 bytes. 头里最多两条: 自己的, 以及公开 peer 轮
690
+ // 的 `→ .对方`。
691
+ const TAG_HEADER_BUDGET = 256 * (2 + MAX_BODY_LINKS);
691
692
  export const startMirrorTail = (deps) => {
692
693
  const { log } = deps;
693
694
  // A single logical tail follows ONE sessionId whose transcript file may move
@@ -1669,16 +1670,11 @@ export const startMirror = (deps) => {
1669
1670
  // renderLine, the tick and the transcript parser all agree on what counts
1670
1671
  // as a ping. Content is the source of truth for the swallow: timers and
1671
1672
  // echo-TTLs lose it across reloads/replays.
1672
- const keepalivePingSigs = [cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing]
1673
- .map((p) => normAssistant(p).slice(0, 40))
1674
- .filter((s) => s.length > 0);
1673
+ const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing);
1675
1674
  // The warmer alone — what the peer readers (peek / wait / roster summary) strip.
1676
1675
  // `resumePing` is excluded on purpose: the turn it opens is real work resumed.
1677
- const warmerSigs = [normAssistant(cfg.wrc.mirror.keepalive.ping).slice(0, 40)].filter(Boolean);
1678
- const isKeepalivePing = (text) => {
1679
- const n = normAssistant(text);
1680
- return n.toLowerCase() === "ping" || keepalivePingSigs.some((sig) => n.includes(sig));
1681
- };
1676
+ const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1677
+ const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
1682
1678
  // ── Typewriter stream lifecycle ────────────────────────────────────
1683
1679
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1684
1680
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -1832,9 +1828,10 @@ export const startMirror = (deps) => {
1832
1828
  // 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
1833
1829
  // `emoji #tag` (withLinkedTagHeader 自己兜)。
1834
1830
  const chatTurnUrl = (target, turnId) => turnId
1835
- ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))
1831
+ ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
1836
1832
  : undefined;
1837
- const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq);
1833
+ // `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
1834
+ const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo);
1838
1835
  // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1839
1836
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
1840
1837
  // 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
@@ -2188,8 +2185,8 @@ export const startMirror = (deps) => {
2188
2185
  };
2189
2186
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
2190
2187
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
2191
- // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
2192
- const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target)));
2188
+ // wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
2189
+ const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target)));
2193
2190
  // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2194
2191
  // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2195
2192
  // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
@@ -2265,18 +2262,40 @@ export const startMirror = (deps) => {
2265
2262
  a.pendingFrom = undefined;
2266
2263
  return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
2267
2264
  };
2265
+ /** 公开派活的那个同伴 —— 回复的头要标给它 (见 AttachState.replyTo)。 */
2266
+ const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
2268
2267
  /** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
2269
- * home, 其余续写 (收口后的补写) 沿用上一轮。 */
2270
- const consumeChannel = (a, reset) => {
2268
+ * home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。 */
2269
+ const consumeChannel = (a, reset, from) => {
2271
2270
  const p = a.pendingChannel;
2272
2271
  a.pendingChannel = undefined;
2272
+ const seeded = a.channelSeeded;
2273
+ a.channelSeeded = true;
2273
2274
  if (p && Date.now() - p.at <= ORIGIN_TTL_MS) {
2274
2275
  a.channel = p.channel === baseOfKey(a.target) ? undefined : p.channel;
2275
2276
  a.speaker = p.speaker;
2277
+ a.replyTo = publicPeer(from);
2278
+ a.convFrom = from;
2276
2279
  }
2277
2280
  else if (reset) {
2278
2281
  a.channel = undefined;
2279
2282
  a.speaker = undefined;
2283
+ a.replyTo = undefined;
2284
+ a.convFrom = undefined;
2285
+ }
2286
+ else if (from) {
2287
+ a.convFrom = from;
2288
+ }
2289
+ else if (!seeded) {
2290
+ // reload 后的第一轮续写: 内存里的频道随进程没了, 缺省 = home 会把一段私聊的
2291
+ // 后续记成群里公开说的 (rolepage 上私聊窗口从此不再更新)。接回最近一轮的印章。
2292
+ const last = lastChannelOf(a.target);
2293
+ if (last) {
2294
+ a.channel = last.channel === baseOfKey(a.target) ? undefined : last.channel;
2295
+ a.speaker = last.speaker;
2296
+ a.replyTo = publicPeer(last.from);
2297
+ a.convFrom = last.from;
2298
+ }
2280
2299
  }
2281
2300
  };
2282
2301
  /** 本轮落 turn 记录的频道字段。 */
@@ -2295,8 +2314,9 @@ export const startMirror = (deps) => {
2295
2314
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2296
2315
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2297
2316
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2298
- consumeChannel(a, true);
2299
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
2317
+ const from = consumeFrom(a);
2318
+ consumeChannel(a, true, from);
2319
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from, ...channelFields(a) });
2300
2320
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2301
2321
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2302
2322
  const q = { turnId, bubble, isSlash };
@@ -2342,8 +2362,10 @@ export const startMirror = (deps) => {
2342
2362
  const fromCli = a.pendingFromCli === true;
2343
2363
  a.pendingFromCli = false;
2344
2364
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2345
- consumeChannel(a, fromCli);
2346
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
2365
+ const fresh = consumeFrom(a);
2366
+ consumeChannel(a, fromCli, fresh);
2367
+ const from = fresh ?? a.convFrom;
2368
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from, ...channelFields(a) });
2347
2369
  a.briefTurnId = turnId;
2348
2370
  a.briefBubble = undefined;
2349
2371
  a.briefIsSlash = false;
@@ -2899,7 +2921,7 @@ export const startMirror = (deps) => {
2899
2921
  // transcript, not on the word alone: a real answer that mentions pong has a
2900
2922
  // real question in front of it.
2901
2923
  if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
2902
- const ping = openKeepalivePing(a.jsonlPath, keepalivePingSigs);
2924
+ const ping = openKeepalivePing(a.jsonlPath, pingSigs);
2903
2925
  if (ping)
2904
2926
  beginKeepaliveByContent(a, ping);
2905
2927
  }
@@ -4807,11 +4829,12 @@ export const startMirror = (deps) => {
4807
4829
  continue; // cache already cold — a ping would cold-rewrite for nothing
4808
4830
  if (k.round >= kc.rounds)
4809
4831
  continue; // budget spent — let it go cold
4810
- // Stall recovery, decided by RULE only (no model self-judgment): the last
4811
- // transcript turn is a synthetic API-error/limit line, or the idle pane
4812
- // still shows an error banner ⇒ a turn died mid-work. Send the resume
4832
+ // Stall recovery, decided by RULE only (no model self-judgment) and from
4833
+ // the transcript's structure only — never the screen: the turn is still
4834
+ // unfinished (died on a synthetic API error, or a tool result nobody
4835
+ // answered) and has been quiet for the whole idle window. Send the resume
4813
4836
  // instruction instead of the plain warmer.
4814
- const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
4837
+ const stalled = kc.resumeOnStall && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), idleTriggerMs, now);
4815
4838
  k.pinging = true;
4816
4839
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
4817
4840
  await fireKeepalive(a, stalled);