wezard 1.2.9 → 1.2.10

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.
@@ -393,7 +393,7 @@ const renderToolInputCompact = (input, max) => {
393
393
  const renderToolUseGroupBody = (calls, deps) => {
394
394
  const renderOne = (c) => ({
395
395
  compact: safeForMarkdown(renderToolInputCompact(c.input, deps.toolUseInlineMaxChars)),
396
- url: deps.detailUrlFor(c.toolUseId),
396
+ url: deps.detailUrlFor(c.toolUseId, deps.target),
397
397
  });
398
398
  if (calls.length === 1) {
399
399
  const c = calls[0];
@@ -489,7 +489,7 @@ const renderLine = (raw, deps) => {
489
489
  if (toolUseId)
490
490
  recordToolResult(toolUseId, full);
491
491
  const compact = safeForMarkdown(oneLineSummary(full, 40));
492
- const url = deps.detailUrlFor(toolUseId);
492
+ const url = deps.detailUrlFor(toolUseId, deps.target);
493
493
  out.push({
494
494
  kind: "tool_result",
495
495
  toolUseId,
@@ -574,13 +574,12 @@ const renderLine = (raw, deps) => {
574
574
  const rawIn = u.input_tokens ?? 0;
575
575
  const cr = u.cache_read_input_tokens ?? 0;
576
576
  const cw = u.cache_creation_input_tokens ?? 0;
577
- // CodeBuddy (claude-internal) 网关把 input_tokens 报成 cr+cw+fresh 的总和,
577
+ // 网关把 input_tokens 报成 cr+cw+fresh 的总和 (claude-4.7-opus, deepseek-v4-flash),
578
578
  // 而 Anthropic 官方 input_tokens 只含 fresh (与 cache_creation/cache_read disjoint)。
579
- // 按模型命名风格判定源头: CodeBuddy "claude-4.7-opus" (点号版本),
580
- // Anthropic 官方是 "claude-opus-4-7" (纯连字符)。只对 CodeBuddy 风格反推,
581
- // 普通 Anthropic-native 会话保持原样, 不影响其 usage 计算。
582
- const isGatewayTotalized = typeof model === "string" && /\d\.\d/.test(model);
583
- const input = isGatewayTotalized && rawIn >= cr + cw ? rawIn - cr - cw : rawIn;
579
+ // 判据用数据本身: input_tokens 是否已覆盖两个 cache 档 —— 覆盖即网关口径, 反推 fresh;
580
+ // 否则按原生口径 (input fresh) 原样保留。不按模型名风格猜, deepseek 无点号版本。
581
+ const isTotalized = rawIn >= cr + cw;
582
+ const input = isTotalized ? rawIn - cr - cw : rawIn;
584
583
  out.push({
585
584
  kind: "turn_usage",
586
585
  model,
@@ -1647,7 +1646,8 @@ export const startMirror = (deps) => {
1647
1646
  const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
1648
1647
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
1649
1648
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
1650
- const briefDetailLink = (turnId) => `✴️ [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId)})`;
1649
+ // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
1650
+ const briefDetailLink = (turnId, target) => `✴️ [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))})`;
1651
1651
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1652
1652
  // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1653
1653
  const finishBubble = async (a, b, content) => {
@@ -1700,7 +1700,7 @@ export const startMirror = (deps) => {
1700
1700
  log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
1701
1701
  closeBriefTurn(a);
1702
1702
  }
1703
- void finishBubble(a, bubble, briefDetailLink(turnId));
1703
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
1704
1704
  }, HARD_TIMEOUT_MS);
1705
1705
  if (a.briefTurnId) {
1706
1706
  (a.briefQueue ??= []).push(q);
@@ -1733,7 +1733,7 @@ export const startMirror = (deps) => {
1733
1733
  a.briefHadTool = false;
1734
1734
  a.briefConcluded = false;
1735
1735
  a.briefLastText = undefined;
1736
- enqueueStandalone(a, briefDetailLink(turnId));
1736
+ enqueueStandalone(a, briefDetailLink(turnId, a.target));
1737
1737
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
1738
1738
  };
1739
1739
  // 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
@@ -1744,7 +1744,7 @@ export const startMirror = (deps) => {
1744
1744
  if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
1745
1745
  return;
1746
1746
  a.briefHadTool = true;
1747
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1747
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1748
1748
  };
1749
1749
  // 本轮结论落地, 只生效一次:
1750
1750
  // • 无工具 → 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
@@ -1758,7 +1758,7 @@ export const startMirror = (deps) => {
1758
1758
  if (a.briefHadTool || !a.briefBubble)
1759
1759
  sendStandalone(a, body);
1760
1760
  else
1761
- void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId)}`);
1761
+ void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId, a.target)}`);
1762
1762
  // 排队中的 turn 已经建了记录但还没跑, 不能被这把扫帚扫成「已完成」。
1763
1763
  const keep = [turnId, ...(a.briefQueue ?? []).map((q) => q.turnId)];
1764
1764
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
@@ -1773,7 +1773,7 @@ export const startMirror = (deps) => {
1773
1773
  // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
1774
1774
  // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
1775
1775
  if (a.briefBubble && !a.briefBubble.done) {
1776
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1776
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1777
1777
  }
1778
1778
  recordTurnClose(a.briefTurnId);
1779
1779
  log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
@@ -2216,8 +2216,10 @@ export const startMirror = (deps) => {
2216
2216
  // Click-to-detail URL for a tool_use id. Cached on the bridge so both
2217
2217
  // attach() and migrateAttachment() share the same closure. Returns ""
2218
2218
  // when disabled in config — renderLine then drops the markdown wrapping.
2219
- const detailUrlFor = (id) => cfg.daemon.detailLinksInMirror && id
2220
- ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id)
2219
+ // `principal` flows through to ww_uniq so all detail links from the same
2220
+ // chat reuse one WeCom inner-browser window.
2221
+ const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
2222
+ ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
2221
2223
  : "";
2222
2224
  const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
2223
2225
  const target = resolveTarget(targetOverride);
@@ -2601,8 +2603,16 @@ export const startMirror = (deps) => {
2601
2603
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
2602
2604
  // worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
2603
2605
  // drift 上下文是延续的, 让 store 去推它那条中性的 "switch"。
2604
- if (jsonlIsPostClearChild(newJsonlPath))
2606
+ if (jsonlIsPostClearChild(newJsonlPath)) {
2605
2607
  a.ctxCut = "clear";
2608
+ // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
2609
+ // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
2610
+ // 不在这里停下的话旧时钟会继续 ping 一个空上下文。busy-resume(过 grace)
2611
+ // 或真实 inbound 解除。下方 store.set 一并落盘。
2612
+ a.keepaliveOff = true;
2613
+ a.keepaliveOffAt = Date.now();
2614
+ a.keepalive = undefined;
2615
+ }
2606
2616
  a.tail.stop();
2607
2617
  bySessionId.delete(oldSessionId);
2608
2618
  a.sessionId = newSessionId;
@@ -2634,6 +2644,10 @@ export const startMirror = (deps) => {
2634
2644
  tmuxPane: a.tmuxPane || undefined,
2635
2645
  cwd: a.runningCwd || undefined,
2636
2646
  pendingCwd: a.pendingCwd || undefined,
2647
+ // store.set 是整记录替换 —— 必须带上暂停态,否则迁移会把 dispatch 刚落盘
2648
+ // 的 /clear 暂停(或之前的 /stop 暂停)从盘上抹掉,reload 后保活复活。
2649
+ keepaliveOff: a.keepaliveOff || undefined,
2650
+ keepaliveOffAt: a.keepaliveOffAt || undefined,
2637
2651
  });
2638
2652
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
2639
2653
  };
@@ -3201,11 +3215,17 @@ export const startMirror = (deps) => {
3201
3215
  };
3202
3216
  const fireKeepalive = async (a) => {
3203
3217
  const kc = cfg.wrc.mirror.keepalive;
3218
+ // Only the streak's FIRST ping carries the full instruction; consecutive
3219
+ // pings shrink to a bare "ping" — the instruction is already in context,
3220
+ // and the smaller delta means a smaller cache-write and less pane clutter.
3221
+ // keepaliveStamps matches both forms, so the bare one never reads as real
3222
+ // activity. (round is incremented below, after the inject lands.)
3223
+ const text = (a.keepalive?.round ?? 0) > 0 ? "ping" : kc.ping;
3204
3224
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3205
3225
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
3206
3226
  // the turn somehow never emits turn_end, so a later real turn is never muted;
3207
3227
  // it also closes the detail turn so it can't hang open in the chat timeline.
3208
- rememberInject(kc.ping);
3228
+ rememberInject(text);
3209
3229
  a.keepaliveQuiet = setTimeout(() => {
3210
3230
  a.keepaliveQuiet = undefined;
3211
3231
  if (a.keepaliveTurnId) {
@@ -3214,7 +3234,7 @@ export const startMirror = (deps) => {
3214
3234
  }
3215
3235
  }, 60_000);
3216
3236
  const r = await inject({
3217
- text: kc.ping, images: [], cfg,
3237
+ text, images: [], cfg,
3218
3238
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
3219
3239
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3220
3240
  });
@@ -3227,11 +3247,7 @@ export const startMirror = (deps) => {
3227
3247
  }
3228
3248
  const tokens = lastContextTokens(a.jsonlPath);
3229
3249
  const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
3230
- // Denominator tracks the ACTUAL ping cadence (idleTriggerMs = ttlSec - marginSec),
3231
- // not the nominal ttlSec — pings fire marginSec early, so ttlSec would undercount
3232
- // and let `n` exceed `N` (a 9/8). floor keeps the last shown round the last one that fires.
3233
- const cadenceSec = Math.max(30, kc.ttlSec - kc.marginSec);
3234
- const totalRounds = Math.max(1, Math.floor(kc.maxIdleSec / cadenceSec));
3250
+ const totalRounds = Math.max(1, kc.rounds);
3235
3251
  const round = a.keepalive ? (a.keepalive.round += 1) : 1;
3236
3252
  log.info({ target: a.target, tokens, round, totalRounds }, "keepalive: ping injected");
3237
3253
  // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
@@ -3240,7 +3256,7 @@ export const startMirror = (deps) => {
3240
3256
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3241
3257
  const turnId = newTurnId();
3242
3258
  a.keepaliveTurnId = turnId;
3243
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: kc.ping });
3259
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
3244
3260
  if (kc.notify)
3245
3261
  sendStandalone(a, `KeepAlive ${round}/${totalRounds} · ${size}`);
3246
3262
  };
@@ -3253,7 +3269,6 @@ export const startMirror = (deps) => {
3253
3269
  try {
3254
3270
  const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
3255
3271
  const ttlMs = kc.ttlSec * 1000;
3256
- const maxIdleMs = Math.max(kc.ttlSec, kc.maxIdleSec) * 1000;
3257
3272
  const pingSig = normAssistant(kc.ping).slice(0, 40);
3258
3273
  const now = Date.now();
3259
3274
  for (const a of byTarget.values()) {
@@ -3322,13 +3337,12 @@ export const startMirror = (deps) => {
3322
3337
  if (a.keepaliveOff)
3323
3338
  continue; // paused by /stop until real activity returns
3324
3339
  const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
3325
- const realIdle = k.lastRealMs ? now - k.lastRealMs : Infinity; // no real turn in window ⇒ dead
3326
3340
  if (idleSinceTouch < idleTriggerMs)
3327
3341
  continue; // cache still comfortably warm
3328
3342
  if (idleSinceTouch >= ttlMs)
3329
3343
  continue; // cache already cold — a ping would cold-rewrite for nothing
3330
- if (realIdle >= maxIdleMs)
3331
- continue; // real work too old stop, let it die (你的「超5分钟不保活」)
3344
+ if (k.round >= kc.rounds)
3345
+ continue; // budget spent — let it go cold
3332
3346
  k.pinging = true;
3333
3347
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
3334
3348
  await fireKeepalive(a);