wezard 1.3.2 → 1.3.6

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.
@@ -465,11 +465,17 @@ const renderLine = (raw, deps) => {
465
465
  const goal = goalStartOf(c);
466
466
  if (goal)
467
467
  return [goal];
468
- if (!deps.includeUser)
469
- return [];
470
468
  const text = cleanUserText(c);
471
469
  if (!text)
472
470
  return []; // pure slash-command meta / stdout — drop
471
+ // Keepalive ping user line: judged by CONTENT, before every other gate
472
+ // (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
473
+ // the reply turn is swallowed by content in onItem. Timer windows can't
474
+ // cover reloads or replays; the user message itself always can.
475
+ if (deps.isKeepalivePing?.(text))
476
+ return [{ kind: "keepalive_start", body: text }];
477
+ if (!deps.includeUser)
478
+ return [];
473
479
  if (deps.isOwnInject(text))
474
480
  return []; // dedupe WeCom→CLI echo
475
481
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
@@ -1414,6 +1420,19 @@ export const startMirror = (deps) => {
1414
1420
  recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
1415
1421
  return true;
1416
1422
  };
1423
+ // Keepalive ping detector for the tail's user-line judgment — the SAME
1424
+ // normalized-prefix signature set keepaliveTick feeds keepaliveStamps
1425
+ // (every configured form + the bare "ping" legacy streak form), so
1426
+ // renderLine, the tick and the transcript parser all agree on what counts
1427
+ // as a ping. Content is the source of truth for the swallow: timers and
1428
+ // echo-TTLs lose it across reloads/replays.
1429
+ const keepalivePingSigs = [cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing]
1430
+ .map((p) => normAssistant(p).slice(0, 40))
1431
+ .filter((s) => s.length > 0);
1432
+ const isKeepalivePing = (text) => {
1433
+ const n = normAssistant(text);
1434
+ return n.toLowerCase() === "ping" || keepalivePingSigs.some((sig) => n.includes(sig));
1435
+ };
1417
1436
  // ── Typewriter stream lifecycle ────────────────────────────────────
1418
1437
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1419
1438
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -1818,7 +1837,7 @@ export const startMirror = (deps) => {
1818
1837
  // 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
1819
1838
  // 让群里从收到消息那一刻起就有详情页入口。收口时:
1820
1839
  // • 正文到位 → 以 `链接 正文` 覆盖这条气泡。
1821
- // • 始终没正文 / 过 6min 窗口 → ack 时那条纯 `链接` 收口。
1840
+ // • 始终没正文 / 过 6min 窗口 → 以最新 CoT 进度行 (没有则纯 `链接`) 收口。
1822
1841
  // • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
1823
1842
  // 其它所有 item 只写入 turn detail store。
1824
1843
  // 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
@@ -1857,6 +1876,7 @@ export const startMirror = (deps) => {
1857
1876
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
1858
1877
  if (!text || !b || b.done || !turnId || a.briefConcluded)
1859
1878
  return;
1879
+ a.cotLastSent = text;
1860
1880
  try {
1861
1881
  await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
1862
1882
  }
@@ -1951,8 +1971,11 @@ export const startMirror = (deps) => {
1951
1971
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
1952
1972
  const q = { turnId, bubble, isSlash };
1953
1973
  bubble.hardTimer = setTimeout(() => {
1954
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
1955
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
1974
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。没正文时不能拿光链接
1975
+ // 覆盖 —— finish 是整条替换, 会把屏幕上的 CoT 进度行抹成光秃秃的链接; 改用
1976
+ // 最新进度行定格 (a 上的 CoT 只在气泡仍是本轮活跃气泡时才可信, 换轮后归新轮)。
1977
+ const cot = a.briefBubble === bubble && !a.briefConcluded ? a.cotText ?? a.cotLastSent : undefined;
1978
+ void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${cot ? ` \`${cot}\`` : ""}`, true);
1956
1979
  }, HARD_TIMEOUT_MS);
1957
1980
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
1958
1981
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
@@ -2232,13 +2255,41 @@ export const startMirror = (deps) => {
2232
2255
  if (a.liveStream && !a.liveStream.closed)
2233
2256
  void finalizeStream(a, a.liveStream);
2234
2257
  };
2235
- // Close the keepalive quiet window — the ping turn is over (or the fail-safe
2236
- // fired), so subsequent real items flow through onItem normally again.
2237
- const endKeepaliveQuiet = (a) => {
2238
- if (!a.keepaliveQuiet)
2239
- return;
2240
- clearTimeout(a.keepaliveQuiet);
2241
- a.keepaliveQuiet = undefined;
2258
+ // End the keepalive swallow — the ping turn is over (turn_end / genuine
2259
+ // user line / inject failure), so real items flow through onItem again.
2260
+ // Clears BOTH the timer window and the content flag.
2261
+ const endKeepaliveSwallow = (a) => {
2262
+ if (a.keepaliveQuiet) {
2263
+ clearTimeout(a.keepaliveQuiet);
2264
+ a.keepaliveQuiet = undefined;
2265
+ }
2266
+ if (a.keepaliveContentTimer) {
2267
+ clearTimeout(a.keepaliveContentTimer);
2268
+ a.keepaliveContentTimer = undefined;
2269
+ }
2270
+ a.keepaliveByContent = undefined;
2271
+ };
2272
+ // Content-based swallow entry: the tailed user line IS a configured ping.
2273
+ // Belt to the inject-time timer window — this one keys on the user message
2274
+ // itself, so it holds across daemon reloads, tail replays and replies
2275
+ // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
2276
+ // have opened (fireKeepalive skips opening when this got there first).
2277
+ const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
2278
+ const beginKeepaliveByContent = (a, text) => {
2279
+ a.keepaliveByContent = true;
2280
+ if (a.keepaliveContentTimer)
2281
+ clearTimeout(a.keepaliveContentTimer);
2282
+ // Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
2283
+ // must not mute a later real turn forever.
2284
+ a.keepaliveContentTimer = setTimeout(() => {
2285
+ a.keepaliveContentTimer = undefined;
2286
+ a.keepaliveByContent = undefined;
2287
+ }, KEEPALIVE_CONTENT_FAILSAFE_MS);
2288
+ if (!a.keepaliveTurnId) {
2289
+ const turnId = newTurnId();
2290
+ a.keepaliveTurnId = turnId;
2291
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
2292
+ }
2242
2293
  };
2243
2294
  // A keepalive ping turn is swallowed wholesale — the reply content doesn't
2244
2295
  // matter (model may add extra commentary beyond "pong", that's fine).
@@ -2246,12 +2297,24 @@ export const startMirror = (deps) => {
2246
2297
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
2247
2298
  if (a.muteUntilInject)
2248
2299
  return;
2249
- // Keepalive ping turns are cache-warmers: swallow every item from the WeCom
2250
- // paths so the ping/pong never reaches chat. But record the REAL exchange
2251
- // into its chat-detail turn — the actual assistant reply, the tool calls if
2252
- // any, and the usage (proof it was a cheap cache-read) so the detail page
2253
- // shows the genuine heartbeat, not a synthetic summary.
2254
- if (a.keepaliveQuiet) {
2300
+ // Keepalive ping turns are cache-warmers: swallow every item from every
2301
+ // WeCom path so the ping/pong never reaches chat. Two triggers, either
2302
+ // suffices — the timer window opened at inject (keepaliveQuiet) OR the
2303
+ // user line itself matching a configured ping (keepaliveByContent; the
2304
+ // reload/replay/slow-reply-proof one). The REAL exchange is recorded
2305
+ // into its chat-detail turn — the actual assistant reply, the tool calls
2306
+ // if any, and the usage — so the detail page shows the genuine heartbeat.
2307
+ if (item.kind === "keepalive_start") {
2308
+ beginKeepaliveByContent(a, item.body);
2309
+ return;
2310
+ }
2311
+ // A genuine user line (not our inject, not a ping) is a hard turn
2312
+ // boundary — any keepalive swallow still holding never got its turn_end
2313
+ // (crashed reply). Release BEFORE the swallow gate so this real line (and
2314
+ // its reply) mirror normally instead of being eaten by the stale swallow.
2315
+ if (item.kind === "user_text")
2316
+ endKeepaliveSwallow(a);
2317
+ if (a.keepaliveQuiet || a.keepaliveByContent) {
2255
2318
  const id = a.keepaliveTurnId;
2256
2319
  if (id) {
2257
2320
  const now = Date.now();
@@ -2270,7 +2333,7 @@ export const startMirror = (deps) => {
2270
2333
  }
2271
2334
  }
2272
2335
  if (item.kind === "turn_end") {
2273
- endKeepaliveQuiet(a);
2336
+ endKeepaliveSwallow(a);
2274
2337
  }
2275
2338
  return;
2276
2339
  }
@@ -2610,6 +2673,7 @@ export const startMirror = (deps) => {
2610
2673
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2611
2674
  isOwnInject,
2612
2675
  isOwnAssistantSend,
2676
+ isKeepalivePing,
2613
2677
  onItem: (item) => onItem(a, item),
2614
2678
  detailUrlFor,
2615
2679
  sessionId,
@@ -2991,6 +3055,7 @@ export const startMirror = (deps) => {
2991
3055
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2992
3056
  isOwnInject,
2993
3057
  isOwnAssistantSend,
3058
+ isKeepalivePing,
2994
3059
  onItem: (item) => onItem(a, item),
2995
3060
  detailUrlFor,
2996
3061
  sessionId: newSessionId,
@@ -3289,33 +3354,13 @@ export const startMirror = (deps) => {
3289
3354
  // from the running cwd). Decoupling means /pwd can show truth even after
3290
3355
  // the AI sets a new path but the user hasn't /new'd yet.
3291
3356
  const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
3292
- // Long absolute paths wrap in the WeCom bubble — show only the trailing
3293
- // three segments, which is enough to identify the project.
3294
- const shortCwd = (p) => p.split("/").filter(Boolean).slice(-3).join("/");
3295
- const renderProjectInfo = (target) => {
3296
- const a = byTarget.get(target);
3297
- const rec = a ? undefined : deps.store.get(target);
3298
- const running = (a?.runningCwd?.trim()) || rec?.cwd?.trim() || expandedDefaultCwd;
3299
- // pendingCwd is chat-scoped — the queued switch applies to every session
3300
- // in this chat, so read it from the shared base slot rather than the
3301
- // caller's own (now-empty) attachment record.
3302
- const pending = chatCwdFallback(target).pending;
3303
- const lines = [`📂 cwd: \`${shortCwd(running)}\``];
3304
- if (pending && pending !== running) {
3305
- lines.push(`下次切换: \`${shortCwd(pending)}\` (使用 /new 或 /clear 生效)`);
3306
- }
3307
- // Session-boundary footer — `/new` and `/clear` are the only two callers,
3308
- // so the tip lands exactly once per fresh context, never mid-conversation.
3309
- lines.push(randomTip());
3310
- return lines.join("\n");
3311
- };
3312
3357
  // `header` folds the caller's ack ("created") into this same bubble — /new
3313
3358
  // must land as exactly ONE WeCom message, not card + separate reply.
3314
- // slashAckFirstLine: 会话边界回执只保留首行 ack, 📂 项目信息与 💡 tip 省去。
3359
+ // 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
3360
+ // slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
3315
3361
  const pushProjectInfo = (target, header) => {
3316
3362
  const concise = cfg.wrc.mirror.slashAckFirstLine && !!header;
3317
- const info = concise ? "" : renderProjectInfo(target);
3318
- const md = header ? (info ? `${header}\n\n${info}` : header) : info;
3363
+ const md = header ? (concise ? header : `${header}\n\n${randomTip()}`) : randomTip();
3319
3364
  const a = byTarget.get(target);
3320
3365
  if (a) {
3321
3366
  sendStandalone(a, md);
@@ -3332,7 +3377,7 @@ export const startMirror = (deps) => {
3332
3377
  // (default + any `#tag` siblings) share one cwd/pendingCwd, tracked on the
3333
3378
  // BASE principal's byTarget/store record. Tagged sessions still have their
3334
3379
  // own `runningCwd` (the tmux pane's actual working dir at spawn time), but
3335
- // cwd fallbacks and `cd` pendingCwd writes always resolve against the base.
3380
+ // cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
3336
3381
  const chatCwdFallback = (target) => {
3337
3382
  const base = basePrincipalOf(target);
3338
3383
  const baseA = byTarget.get(base);
@@ -3351,7 +3396,7 @@ export const startMirror = (deps) => {
3351
3396
  // base.pending > caller.pending > target.running > base.running > default
3352
3397
  // A fresh tagged session inherits the chat's current cwd; re-`/new`ing a
3353
3398
  // live tagged session keeps its pane cwd unless the base session queued a
3354
- // `cd`. This keeps siblings aligned by default without forcibly clobbering
3399
+ // cwd switch. This keeps siblings aligned by default without forcibly clobbering
3355
3400
  // an already-spawned tagged pane on every base-cwd change.
3356
3401
  const chat = chatCwdFallback(target);
3357
3402
  const rec = !prev ? deps.store.get(target) : undefined;
@@ -3449,9 +3494,9 @@ export const startMirror = (deps) => {
3449
3494
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
3450
3495
  };
3451
3496
  const getCwd = (target) => {
3452
- // pendingCwd is chat-scoped — a `cd` from any sibling session queues the
3453
- // switch for the whole chat. runningCwd stays per-session (each pane has
3454
- // its own spawn dir).
3497
+ // pendingCwd is chat-scoped — a `set_workspace` from any sibling session
3498
+ // queues the switch for the whole chat. runningCwd stays per-session (each
3499
+ // pane has its own spawn dir).
3455
3500
  const chat = chatCwdFallback(target);
3456
3501
  const pending = chat.pending;
3457
3502
  const a = byTarget.get(target);
@@ -3463,9 +3508,9 @@ export const startMirror = (deps) => {
3463
3508
  return { runningCwd: chat.running || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
3464
3509
  };
3465
3510
  // Write `pendingCwd` to the BASE principal so the switch applies chat-wide —
3466
- // the next /new in any tagged/untagged session picks it up. `cd` from a
3467
- // tagged session still writes to the shared slot, not the tagged session's
3468
- // own record, matching "sessions share the chat's cwd".
3511
+ // the next /new in any tagged/untagged session picks it up. `set_workspace`
3512
+ // from a tagged session still writes to the shared slot, not the tagged
3513
+ // session's own record, matching "sessions share the chat's cwd".
3469
3514
  const setPendingCwd = (target, cwd) => {
3470
3515
  const trimmed = (cwd ?? "").trim();
3471
3516
  if (!trimmed)
@@ -3627,6 +3672,34 @@ export const startMirror = (deps) => {
3627
3672
  const p = jsonlOf(target);
3628
3673
  return p ? lastAssistantText(p) : "";
3629
3674
  };
3675
+ // ── 未收口气泡的引用判定 ────────────────────────────────────────────
3676
+ // tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
3677
+ // 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
3678
+ // 不是把这些中间态贴回 prompt —— 判定命中 ⇒ 调用方只保留 tag, 正文丢弃。
3679
+ // URL 必须先于 canon 剥掉: canonForCompare 只剩字母数字, host/path 的字符会
3680
+ // 永远卡住比对。两侧同剥, 才在同一层级上比。
3681
+ const OPEN_QUOTE_URL_RE = /https?:\/\/\S+/g;
3682
+ const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
3683
+ const openBubbleBodies = (a) => {
3684
+ if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
3685
+ return [a.cotLastSent, a.cotText].filter((s) => !!s);
3686
+ }
3687
+ const s = a.liveStream;
3688
+ return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
3689
+ };
3690
+ const isOpenBubbleQuote = (target, quoted) => {
3691
+ const a = byTarget.get(target);
3692
+ if (!a)
3693
+ return false;
3694
+ const bodies = openBubbleBodies(a);
3695
+ if (bodies.length === 0)
3696
+ return false;
3697
+ const qc = quoteCanon(quoted);
3698
+ // 剥掉 URL 后什么都不剩 ⇒ 引用的只是链接头 + "…" 这类纯 chrome, 直接命中。
3699
+ if (!qc)
3700
+ return true;
3701
+ return bodies.some((b) => quoteCanon(b).includes(qc));
3702
+ };
3630
3703
  // 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
3631
3704
  // `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
3632
3705
  const peerInfoOf = async (t, self) => {
@@ -3756,7 +3829,7 @@ export const startMirror = (deps) => {
3756
3829
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3757
3830
  });
3758
3831
  if (!r.ok) {
3759
- endKeepaliveQuiet(a);
3832
+ endKeepaliveSwallow(a);
3760
3833
  if (a.keepalive)
3761
3834
  a.keepalive.pinging = false; // roll back so the next tick can retry
3762
3835
  log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
@@ -3769,9 +3842,13 @@ export const startMirror = (deps) => {
3769
3842
  // actual ping we injected; the assistant reply (expected: just "pong"), any
3770
3843
  // tool calls, and usage are grafted on from the swallowed items in onItem;
3771
3844
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3772
- const turnId = newTurnId();
3773
- a.keepaliveTurnId = turnId;
3774
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
3845
+ // The tail's keepalive_start (content match on the user line) may have
3846
+ // opened this turn already — only open when it hasn't.
3847
+ if (!a.keepaliveTurnId) {
3848
+ const turnId = newTurnId();
3849
+ a.keepaliveTurnId = turnId;
3850
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
3851
+ }
3775
3852
  };
3776
3853
  let keepaliveTicking = false;
3777
3854
  const keepaliveTick = async () => {
@@ -3891,6 +3968,7 @@ export const startMirror = (deps) => {
3891
3968
  peekTurns,
3892
3969
  isBusy,
3893
3970
  lastText,
3971
+ isOpenBubbleQuote,
3894
3972
  status: () => {
3895
3973
  const list = Array.from(bySessionId.values()).map((a) => ({
3896
3974
  sessionId: a.sessionId,
@@ -3917,7 +3995,7 @@ export const startMirror = (deps) => {
3917
3995
  },
3918
3996
  targetForSession: (sessionId) => {
3919
3997
  // Live attach is the fast path. Fall back to scanning persisted store:
3920
- // an MCP tool (e.g. `cd`) called from a claude that isn't mirror-attached
3998
+ // an MCP tool (e.g. `set_workspace`) called from a claude that isn't mirror-attached
3921
3999
  // would otherwise miss here and silently retarget to defaultChat — that
3922
4000
  // bug stranded pendingCwd on the wrong principal. Store keeps each
3923
4001
  // target's sessionId in sync via migrate/attach/setPendingCwd writes.
@@ -4572,10 +4650,10 @@ export const startMirror = (deps) => {
4572
4650
  // and surface a standalone "cleared" so the user gets explicit
4573
4651
  // feedback (the skip-stream path otherwise leaves WeCom silent).
4574
4652
  if (armMigration) {
4575
- // slashAckFirstLine: 只回首行 ack, 项目信息/tip 省去 (与 /new 一致)。
4653
+ // cwd 不随回执下发 (/pwd 可查); slashAckFirstLine tip 也省去 (与 /new 一致)。
4576
4654
  sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
4577
4655
  ? "cleared"
4578
- : `cleared\n\n${renderProjectInfo(a.target)}`);
4656
+ : `cleared\n\n${randomTip()}`);
4579
4657
  a.clearRebind = { baseline: preClearBaseline };
4580
4658
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
4581
4659
  }