wezard 1.2.27 → 1.2.29

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.
@@ -368,17 +368,6 @@ const stripTrailingBody = (think, body) => {
368
368
  }
369
369
  return think;
370
370
  };
371
- // thinkStyle 兜底: turn 收口时无正式 body, 从累积的 thinking 里摘出最后一段
372
- // 看起来像正文的内容当作 body 下发。工具调用行 (🔧) 和工具结果行 (↳) 跳过。
373
- const rescueBodyFromThinking = (thinking) => {
374
- const paragraphs = thinking.split(/\n\n/).map(s => s.trim()).filter((s) => s.length > 0);
375
- for (let i = paragraphs.length - 1; i >= 0; i--) {
376
- const p = paragraphs[i];
377
- if (!p.startsWith("🔧 ") && !p.startsWith("↳ "))
378
- return p;
379
- }
380
- return undefined;
381
- };
382
371
  // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
383
372
  // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
384
373
  // `<noreply@x>` etc. silently swallows the rest of the line plus the closing
@@ -1455,13 +1444,14 @@ export const startMirror = (deps) => {
1455
1444
  const EARLY_LINK_MS = 3_000;
1456
1445
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1457
1446
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1458
- * 这一段, 与模型思考/工具执行时长无关。 */
1459
- const SOFT_TURN_END_MS = 4_000;
1447
+ * 这一段, 与模型思考/工具执行时长无关。
1448
+ * codebuddy 默认 10s (实测 <40ms, 但留余量防 fs.watch 抖动 / 落盘延迟);
1449
+ * claude 默认 4s (硬信号兜底, 软收口极少触发)。可由 config 覆盖。 */
1450
+ const SOFT_TURN_END_DEFAULT_CLAUDE = 4_000;
1451
+ const SOFT_TURN_END_DEFAULT_CB = 10_000;
1452
+ const softTurnEndMsFor = (a) => cfg.wrc.mirror.softTurnEndMs ?? (backendForPath(a.jsonlPath).name === "codebuddy" ? SOFT_TURN_END_DEFAULT_CB : SOFT_TURN_END_DEFAULT_CLAUDE);
1460
1453
  const STREAM_SOFT_CAP = 18_000;
1461
1454
  const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1462
- /** thinkStyle 兜底超时: turn 开始 2min 后仍未 conclude → 强制软收口。
1463
- * 比 HARD_TIMEOUT_MS (350s) 短, 气泡还活着时走 finishBriefBubble 路径。 */
1464
- const THINK_RESCUE_TIMEOUT_MS = 120_000;
1465
1455
  /** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
1466
1456
  * 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
1467
1457
  const STANDALONE_BODY_WAIT_MS = 8_000;
@@ -1652,24 +1642,52 @@ export const startMirror = (deps) => {
1652
1642
  log.warn({ sessionId: a.sessionId, err: e.message }, "pane preamble capture failed");
1653
1643
  }
1654
1644
  };
1645
+ // thinkStyle 格式化: 将 merged standalone 内容分拣为 <think> 包裹的工具行和正文。
1646
+ // 统一在发送层处理, pushThink / handleBriefItem 等上游只需发原始文本。
1647
+ const formatThinkStandalone = (a, merged) => {
1648
+ const lines = merged.split("\n");
1649
+ const thinkLines = [];
1650
+ const bodyLines = [];
1651
+ for (const line of lines) {
1652
+ if (line.startsWith("🔧 ") || line.startsWith("↳ "))
1653
+ thinkLines.push(line);
1654
+ else
1655
+ bodyLines.push(line);
1656
+ }
1657
+ const think = thinkLines.join("\n").trim();
1658
+ const body = bodyLines.join("\n").trim();
1659
+ if (think && body)
1660
+ return `${openThink(a.target, think)}</think>\n\n${body}`;
1661
+ if (think)
1662
+ return `${openThink(a.target, think)}</think>`;
1663
+ return body;
1664
+ };
1655
1665
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1656
1666
  // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
1657
1667
  // 正文检查: 合并内容无正文 (纯 think/工具) 时, 回队列等 STANDALONE_BODY_WAIT_MS;
1658
- // 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才强制发出。
1668
+ // 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才丢弃。
1659
1669
  // force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
1660
1670
  const flushStandalone = (a, force = false) => {
1661
1671
  const buf = a.standaloneBuf;
1662
1672
  if (!buf)
1663
1673
  return;
1664
1674
  const merged = buf.parts.join("\n\n");
1665
- // 无正文 & 队列有新增 → 再等一轮 (force 跳过)
1666
- if (!force && !standaloneHasBody(merged) && buf.bodyWaitLen !== buf.parts.length) {
1667
- buf.bodyWaitLen = buf.parts.length;
1668
- buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
1675
+ if (!force && !standaloneHasBody(merged)) {
1676
+ if (buf.bodyWaitLen !== buf.parts.length) {
1677
+ // 无正文 & 队列有新增 → 再等一轮, 看后续 item 是否带正文。
1678
+ buf.bodyWaitLen = buf.parts.length;
1679
+ buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
1680
+ return;
1681
+ }
1682
+ // 8s 无新 item 且仍无正文 → 丢弃 (纯 think/工具行不值得单独发一条)。
1683
+ a.standaloneBuf = undefined;
1669
1684
  return;
1670
1685
  }
1671
1686
  a.standaloneBuf = undefined;
1672
- sendStandalone(a, merged);
1687
+ // thinkStyle: 统一格式化 — 工具行包进 <think>, 正文留外面。
1688
+ const content = thinkStyle ? formatThinkStandalone(a, merged) : merged;
1689
+ if (content)
1690
+ sendStandalone(a, content);
1673
1691
  };
1674
1692
  const enqueueStandalone = (a, content) => {
1675
1693
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
@@ -1955,16 +1973,21 @@ export const startMirror = (deps) => {
1955
1973
  }, FLUSH_MS);
1956
1974
  };
1957
1975
  // think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
1958
- // 气泡还活 → 累积进 briefThinking 并排一次去抖 flush; 否则 (无气泡 turn / 气泡已收口)
1959
- // 只累积, 绝不单独 standalone —— 中间纯思考过程不下发消息, 等正式文本回复出现时
1960
- // 与正文拼成一条 `<think>…</think>\n\n正文` 整段下发 (见 concludeBriefTurn)。
1976
+ // 气泡还活 → 累积进 briefThinking 并排一次去抖 flush (replyStream);
1977
+ // 气泡已收口 / 无气泡 enqueueStandalone 即时推送 (自带 3s debounce 防抖合并)。
1961
1978
  const pushThink = (a, chunk) => {
1962
1979
  const c = chunk.trim();
1963
1980
  if (!c)
1964
1981
  return;
1965
- a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
1966
- if (a.briefBubble && !a.briefBubble.done)
1982
+ if (a.briefBubble && !a.briefBubble.done) {
1983
+ a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
1967
1984
  scheduleThinkFlush(a);
1985
+ }
1986
+ else if (a.briefTurnId) {
1987
+ // 气泡已收口 / 无气泡 turn: 走 standalone, 避免长 turn 期间 chat 静默。
1988
+ // 原始内容入队, flushStandalone 统一做 thinkStyle 格式化 (工具行→<think>, 其余→正文)。
1989
+ enqueueStandalone(a, c);
1990
+ }
1968
1991
  };
1969
1992
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1970
1993
  const openBriefTurn = (a, q) => {
@@ -1980,27 +2003,6 @@ export const startMirror = (deps) => {
1980
2003
  a.thinkFlushTimer = undefined;
1981
2004
  }
1982
2005
  a.thinkFlushing = false;
1983
- if (a.thinkRescueTimer) {
1984
- clearTimeout(a.thinkRescueTimer);
1985
- a.thinkRescueTimer = undefined;
1986
- }
1987
- if (thinkStyle) {
1988
- a.thinkRescueTimer = setTimeout(() => {
1989
- if (a.briefTurnId === q.turnId && !a.briefConcluded) {
1990
- log.warn({ sessionId: a.sessionId, turnId: q.turnId, hasThinking: !!a.briefThinking }, "brief: thinkStyle rescue timeout — forcing close");
1991
- // 立即刷一次最新 thinking 到气泡 (跳过去抖), 让用户看到完整积累内容,
1992
- // 而不是停在上一次 250ms 去抖窗口的快照。
1993
- if (a.thinkFlushTimer) {
1994
- clearTimeout(a.thinkFlushTimer);
1995
- a.thinkFlushTimer = undefined;
1996
- }
1997
- if (a.briefBubble && !a.briefBubble.done && a.briefThinking?.trim()) {
1998
- void doStreamThink(a);
1999
- }
2000
- closeBriefTurn(a, true);
2001
- }
2002
- }, THINK_RESCUE_TIMEOUT_MS);
2003
- }
2004
2006
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
2005
2007
  };
2006
2008
  // WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
@@ -2043,9 +2045,9 @@ export const startMirror = (deps) => {
2043
2045
  // 活跃 turn 的 thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
2044
2046
  const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
2045
2047
  const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
2046
- // 标记: thinking 快照已写进气泡, 后续 concludeBriefTurnelse 分支时不再重复发 thinking
2047
- if (think)
2048
- bubble.thinkSnapshotFlushed = true;
2048
+ // 气泡带走了已累积的 thinking; 清空 briefThinking, 后续 pushThinkstandalone
2049
+ if (a.briefBubble === bubble)
2050
+ a.briefThinking = undefined;
2049
2051
  void finishBubble(a, bubble, content, true);
2050
2052
  }
2051
2053
  else {
@@ -2136,29 +2138,28 @@ export const startMirror = (deps) => {
2136
2138
  if (!turnId || a.briefConcluded || !body.trim())
2137
2139
  return;
2138
2140
  a.briefConcluded = true;
2139
- // think-style: thinking 段以 `<think>🧙 …</think>` 收口 (tag 在 <think> 内),
2140
- // 再接正文; 无 thinking 时退回普通 tag 头正文。
2141
- // 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
2142
- // 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
2143
- // 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
2144
- const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
2145
2141
  if (thinkStyle) {
2142
+ // think-style: thinking 段以 `<think>🧙 …</think>` 收口 (tag 在 <think> 内),
2143
+ // 再接正文; 无 thinking 时退回普通 tag 头正文。
2144
+ // 软后端 (softTurnEnd) 的最终正文 final===undefined, 上游按"非 final 中途文本"
2145
+ // 也 pushThink 进过 briefThinking —— 收口再拿它当正文 = think 与正文各一份重复。
2146
+ // 收口前把 briefThinking 尾部等于 body 的那段剥掉, 保证正文只出现在 <think> 之后。
2147
+ const think = stripTrailingBody(a.briefThinking?.trim(), body.trim());
2146
2148
  if (a.briefBubble && !a.briefBubble.done) {
2147
2149
  // 气泡还活: 整段 `<think>🧙 …</think>\n\n正文` 收进这一条 (raw, tag 已在 <think> 内)。
2148
2150
  const out = think ? `${openThink(a.target, think)}</think>\n\n${body}` : withSessionTag(a.target, body);
2149
2151
  void finishBriefBubble(a, out, true);
2150
2152
  }
2151
2153
  else {
2152
- // 无气泡 turn / 气泡已收口: 中间 thinking 不单独 standalone, 累积的整段思考与
2153
- // 正式正文拼成一条下发 (think 取自 stripTrailingBody, 已摘掉尾部等于正文的重复段)
2154
- // fix #1: hardTimer 已把 thinking 快照写进气泡 (thinkSnapshotFlushed), 只发 body,
2155
- // 不再重复发 thinking —— 用户已经在气泡里看到了完整 thinking。
2156
- const snapshotFlushed = a.briefBubble?.thinkSnapshotFlushed;
2157
- const t = snapshotFlushed ? undefined : think;
2158
- if (t)
2159
- sendRaw(a, `${openThink(a.target, t)}</think>\n\n${body}`);
2160
- else
2154
+ // 无气泡 turn / 气泡已收口: briefThinking 此时只含气泡期间累积的残余
2155
+ // (气泡收口后 pushThink 直接走 standalone, 不再累积)。有残余则拼成
2156
+ // think+body 一条; 无残余则只发 body
2157
+ if (think) {
2158
+ sendRaw(a, `${openThink(a.target, think)}</think>\n\n${body}`);
2159
+ }
2160
+ else {
2161
2161
  sendStandalone(a, body);
2162
+ }
2162
2163
  }
2163
2164
  }
2164
2165
  else if (a.briefHadTool || !a.briefBubble) {
@@ -2178,17 +2179,6 @@ export const startMirror = (deps) => {
2178
2179
  return;
2179
2180
  if (soft)
2180
2181
  concludeBriefTurn(a, a.briefLastText ?? "");
2181
- // ──── thinkStyle 兜底: turn 将收口但 concludeBriefTurn 未生效 ────
2182
- // 正常路径 (final===true / 软收口 + briefLastText) 上面已经 conclude 过了;
2183
- // 这里只处理"有 thinking 但正文为空"的漏网之鱼: 从 briefThinking 尾部摘出最后
2184
- // 一段非工具行当作正文, 再试一次 conclude。保证 thinking 内容不被静默丢弃。
2185
- if (thinkStyle && !a.briefConcluded && a.briefThinking) {
2186
- const rescued = rescueBodyFromThinking(a.briefThinking);
2187
- if (rescued) {
2188
- log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: thinkStyle rescue — concluding with last text from thinking");
2189
- concludeBriefTurn(a, rescued);
2190
- }
2191
- }
2192
2182
  // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
2193
2183
  // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
2194
2184
  if (a.briefBubble && !a.briefBubble.done) {
@@ -2227,10 +2217,6 @@ export const startMirror = (deps) => {
2227
2217
  a.thinkFlushTimer = undefined;
2228
2218
  }
2229
2219
  a.thinkFlushing = false;
2230
- if (a.thinkRescueTimer) {
2231
- clearTimeout(a.thinkRescueTimer);
2232
- a.thinkRescueTimer = undefined;
2233
- }
2234
2220
  // 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
2235
2221
  const next = a.briefQueue?.shift();
2236
2222
  if (next)
@@ -2322,7 +2308,7 @@ export const startMirror = (deps) => {
2322
2308
  const now = Date.now();
2323
2309
  if (item.kind === "tool_use") {
2324
2310
  a.briefHadTool = true; // 有工具 → 气泡收口写详情链接, 结论另发 standalone
2325
- earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口 (think-style 下为 no-op)
2311
+ earlyLinkBubble(a); // 立刻推详情链接, 不等 turn 收口 (think-style 下保持气泡开放)
2326
2312
  for (const c of item.calls) {
2327
2313
  // 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
2328
2314
  if (c.toolUseId) {
@@ -2366,27 +2352,26 @@ export const startMirror = (deps) => {
2366
2352
  }
2367
2353
  if (item.kind === "thinking") {
2368
2354
  recordTurnItem(turnId, { t: "text", body: item.body, ts: now });
2369
- if (thinkStyle)
2370
- pushThink(a, item.body); // reasoning 进 think 流
2371
- else
2372
- a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${item.body}` : item.body;
2355
+ // thinking items only emitted by parser when thinkStyle=true (line 736 gate).
2356
+ pushThink(a, item.body); // reasoning 进 think 流
2373
2357
  return;
2374
2358
  }
2375
2359
  if (item.kind === "text") {
2376
2360
  recordTurnItem(turnId, { t: "text", body: item.body, ts: now, final: item.final === true });
2377
2361
  // think-style: 确知的中途输出 (final===false) 只进 think 流, 不算结论;
2378
- // 软后端的不确定态 (final===undefined) 也 pushThink, 但同时写 briefLastText
2379
- // —— 软收口路径 (closeBriefTurn soft=true) briefLastText 当正文传给
2380
- // concludeBriefTurn, 那里 stripTrailingBody 会把 think 尾部的重复段摘掉。
2381
- // 旧逻辑 undefined 一律 return 导致 briefLastText 永远为空, 软收口拿到 ""
2382
- // 直接 bail → 正文留在 think 里出不来。
2362
+ // 软后端的不确定态 (final===undefined) 也 pushThink 气泡活时累积到
2363
+ // briefThinking + 写 briefLastText (收口用); 气泡死时直接 standalone 发出,
2364
+ // 不写 briefLastText (已发, 收口不再重复)。
2383
2365
  if (thinkStyle && item.final === false) {
2384
2366
  pushThink(a, item.body);
2385
2367
  return;
2386
2368
  }
2387
2369
  if (thinkStyle && item.final === undefined) {
2388
2370
  pushThink(a, item.body);
2389
- a.briefLastText = item.body;
2371
+ // 气泡还活时 pushThink 只累积 briefThinking, 收口需要 briefLastText body;
2372
+ // 气泡已死时 pushThink 走了 standalone 已发出, 不写 briefLastText 避免收口重复。
2373
+ if (a.briefBubble && !a.briefBubble.done)
2374
+ a.briefLastText = item.body;
2390
2375
  return;
2391
2376
  }
2392
2377
  a.briefLastText = item.body; // 软收口时拿它当结论 (那条路径上没有 final 标记)
@@ -2411,15 +2396,10 @@ export const startMirror = (deps) => {
2411
2396
  if (item.quiet)
2412
2397
  return;
2413
2398
  // slash 命令 (/context…) 的 skill_output 即本轮答案 (无 final text 收口) —— 写进
2414
- // loading 气泡。非 slash 场景的 skill_output 是中间反馈:
2415
- // think-style → 进 think 流 (与工具调用等中间过程一致);
2416
- // 非 think-style → standalone。
2399
+ // loading 气泡。非 slash 场景的 skill_output 是用户可见的中间反馈, 走 standalone。
2417
2400
  if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
2418
2401
  void finishBriefBubble(a, item.body);
2419
2402
  }
2420
- else if (thinkStyle) {
2421
- pushThink(a, item.body);
2422
- }
2423
2403
  else {
2424
2404
  sendStandalone(a, item.body);
2425
2405
  }
@@ -2531,7 +2511,7 @@ export const startMirror = (deps) => {
2531
2511
  a.softEnd = undefined;
2532
2512
  }
2533
2513
  if (item.kind === "turn_end" && item.soft === true) {
2534
- a.softEnd = setTimeout(() => fireSoftTurnEnd(a), SOFT_TURN_END_MS);
2514
+ a.softEnd = setTimeout(() => fireSoftTurnEnd(a), softTurnEndMsFor(a));
2535
2515
  return;
2536
2516
  }
2537
2517
  // codebuddy: ExitPlanMode 若被本地先答, result 落盘即作废挂着的计划审批卡
@@ -2847,7 +2827,7 @@ export const startMirror = (deps) => {
2847
2827
  log: log.child({ sub: "tail", sessionId }),
2848
2828
  includeUser: cfg.wrc.mirror.includeUser,
2849
2829
  includeTools: cfg.wrc.mirror.includeTools,
2850
- thinkStyle: cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief,
2830
+ thinkStyle,
2851
2831
  includeToolResults: cfg.wrc.mirror.includeToolResults,
2852
2832
  toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
2853
2833
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
@@ -3229,7 +3209,7 @@ export const startMirror = (deps) => {
3229
3209
  log: log.child({ sub: "tail", sessionId: newSessionId }),
3230
3210
  includeUser: cfg.wrc.mirror.includeUser,
3231
3211
  includeTools: cfg.wrc.mirror.includeTools,
3232
- thinkStyle: cfg.wrc.mirror.thinkStyle && cfg.wrc.mirror.brief,
3212
+ thinkStyle,
3233
3213
  includeToolResults: cfg.wrc.mirror.includeToolResults,
3234
3214
  toolResultMaxChars: cfg.wrc.mirror.toolResultMaxChars,
3235
3215
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,