wezard 2.0.6 → 2.1.1

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.
Files changed (45) hide show
  1. package/CLAUDE.md +2 -2
  2. package/dist/daemon/chat-log.js +107 -0
  3. package/dist/daemon/chat-log.js.map +1 -0
  4. package/dist/daemon/chat-name.js +1 -1
  5. package/dist/daemon/detail.js +7 -0
  6. package/dist/daemon/detail.js.map +1 -1
  7. package/dist/daemon/inbound.js +10 -3
  8. package/dist/daemon/inbound.js.map +1 -1
  9. package/dist/daemon/index.js +125 -22
  10. package/dist/daemon/index.js.map +1 -1
  11. package/dist/daemon/mirror-bridge.js +81 -44
  12. package/dist/daemon/mirror-bridge.js.map +1 -1
  13. package/dist/daemon/notices.js +3 -5
  14. package/dist/daemon/notices.js.map +1 -1
  15. package/dist/daemon/peers.js +260 -125
  16. package/dist/daemon/peers.js.map +1 -1
  17. package/dist/daemon/session-name.js +1 -1
  18. package/dist/daemon/session-name.js.map +1 -1
  19. package/dist/daemon/wizard.js +33 -2
  20. package/dist/daemon/wizard.js.map +1 -1
  21. package/dist/mcp/server.js +34 -12
  22. package/dist/mcp/server.js.map +1 -1
  23. package/dist/shared/chat-http.js +25 -10
  24. package/dist/shared/chat-http.js.map +1 -1
  25. package/dist/shared/chat-render.js +17 -7
  26. package/dist/shared/chat-render.js.map +1 -1
  27. package/dist/shared/config.js +6 -6
  28. package/dist/shared/config.js.map +1 -1
  29. package/dist/shared/detail-render.js +22 -5
  30. package/dist/shared/detail-render.js.map +1 -1
  31. package/dist/shared/reminder.js +56 -0
  32. package/dist/shared/reminder.js.map +1 -0
  33. package/dist/shared/role-render.js +6 -3
  34. package/dist/shared/role-render.js.map +1 -1
  35. package/dist/shared/role-view.js +3 -3
  36. package/dist/shared/role-view.js.map +1 -1
  37. package/dist/shared/session-label.js +9 -3
  38. package/dist/shared/session-label.js.map +1 -1
  39. package/hooks/pre-tool-use.sh +1 -1
  40. package/package.json +4 -1
  41. package/web/chat.css +15 -11
  42. package/web/chat.js +38 -5
  43. package/web/highlight.min.js +1262 -0
  44. package/web/hljs-github.min.css +10 -0
  45. package/web/markdown-it.min.js +2 -0
@@ -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 } 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, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastReply, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, tailTurns, 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,13 +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);
1675
- const isKeepalivePing = (text) => {
1676
- const n = normAssistant(text);
1677
- return n.toLowerCase() === "ping" || keepalivePingSigs.some((sig) => n.includes(sig));
1678
- };
1673
+ const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing);
1674
+ // The warmer alone — what the peer readers (peek / wait / roster summary) strip.
1675
+ // `resumePing` is excluded on purpose: the turn it opens is real work resumed.
1676
+ const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1677
+ const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
1679
1678
  // ── Typewriter stream lifecycle ────────────────────────────────────
1680
1679
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1681
1680
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -1831,7 +1830,8 @@ export const startMirror = (deps) => {
1831
1830
  const chatTurnUrl = (target, turnId) => turnId
1832
1831
  ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))
1833
1832
  : undefined;
1834
- 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);
1835
1835
  // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1836
1836
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
1837
1837
  // 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
@@ -2262,18 +2262,40 @@ export const startMirror = (deps) => {
2262
2262
  a.pendingFrom = undefined;
2263
2263
  return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
2264
2264
  };
2265
+ /** 公开派活的那个同伴 —— 回复的头要标给它 (见 AttachState.replyTo)。 */
2266
+ const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
2265
2267
  /** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
2266
- * home, 其余续写 (收口后的补写) 沿用上一轮。 */
2267
- const consumeChannel = (a, reset) => {
2268
+ * home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。 */
2269
+ const consumeChannel = (a, reset, from) => {
2268
2270
  const p = a.pendingChannel;
2269
2271
  a.pendingChannel = undefined;
2272
+ const seeded = a.channelSeeded;
2273
+ a.channelSeeded = true;
2270
2274
  if (p && Date.now() - p.at <= ORIGIN_TTL_MS) {
2271
2275
  a.channel = p.channel === baseOfKey(a.target) ? undefined : p.channel;
2272
2276
  a.speaker = p.speaker;
2277
+ a.replyTo = publicPeer(from);
2278
+ a.convFrom = from;
2273
2279
  }
2274
2280
  else if (reset) {
2275
2281
  a.channel = undefined;
2276
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
+ }
2277
2299
  }
2278
2300
  };
2279
2301
  /** 本轮落 turn 记录的频道字段。 */
@@ -2292,8 +2314,9 @@ export const startMirror = (deps) => {
2292
2314
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2293
2315
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2294
2316
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2295
- consumeChannel(a, true);
2296
- 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) });
2297
2320
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2298
2321
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2299
2322
  const q = { turnId, bubble, isSlash };
@@ -2339,8 +2362,10 @@ export const startMirror = (deps) => {
2339
2362
  const fromCli = a.pendingFromCli === true;
2340
2363
  a.pendingFromCli = false;
2341
2364
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2342
- consumeChannel(a, fromCli);
2343
- 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) });
2344
2369
  a.briefTurnId = turnId;
2345
2370
  a.briefBubble = undefined;
2346
2371
  a.briefIsSlash = false;
@@ -2896,7 +2921,7 @@ export const startMirror = (deps) => {
2896
2921
  // transcript, not on the word alone: a real answer that mentions pong has a
2897
2922
  // real question in front of it.
2898
2923
  if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
2899
- const ping = openKeepalivePing(a.jsonlPath, keepalivePingSigs);
2924
+ const ping = openKeepalivePing(a.jsonlPath, pingSigs);
2900
2925
  if (ping)
2901
2926
  beginKeepaliveByContent(a, ping);
2902
2927
  }
@@ -4499,11 +4524,11 @@ export const startMirror = (deps) => {
4499
4524
  const isBusy = (target) => paneBusy(paneOf(target));
4500
4525
  const lastText = (target) => {
4501
4526
  const p = jsonlOf(target);
4502
- return p ? lastAssistantText(p) : "";
4527
+ return p ? lastAssistantText(p, 4000, warmerSigs) : "";
4503
4528
  };
4504
4529
  const replySince = (target, sinceMs = 0) => {
4505
4530
  const p = jsonlOf(target);
4506
- return p ? lastReply(p, sinceMs) : "";
4531
+ return p ? lastReply(p, sinceMs, warmerSigs) : "";
4507
4532
  };
4508
4533
  // ── 未收口气泡的引用判定 ────────────────────────────────────────────
4509
4534
  // tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
@@ -4565,13 +4590,13 @@ export const startMirror = (deps) => {
4565
4590
  paneAlive,
4566
4591
  busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
4567
4592
  lastActivity,
4568
- summary: jsonlPath ? summarizeTail(jsonlPath) : "(未绑定会话)",
4593
+ summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, warmerSigs) : "(未绑定会话)",
4569
4594
  self: t === self,
4570
4595
  };
4571
4596
  };
4572
4597
  const byRecent = (x, y) => y.lastActivity - x.lastActivity;
4573
4598
  const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
4574
- // list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
4599
+ // 名册的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
4575
4600
  // 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
4576
4601
  const foreignPeers = async (self) => {
4577
4602
  const selfBase = baseOfKey(self);
@@ -4609,27 +4634,23 @@ export const startMirror = (deps) => {
4609
4634
  const hot = ranked.slice(0, WORLD_PROBE).map((x) => x.t);
4610
4635
  return (await mapLimit(hot, WORLD_CONC, (t) => peerInfoOf(t, self))).sort(byRecent);
4611
4636
  };
4612
- // Capture a few extra rows then compact away the TUI's blank padding, so
4613
- // `rows` counts lines the user actually cares about.
4614
- const peekPane = async (target, rows = 24) => {
4615
- const pane = paneOf(target);
4616
- if (!pane)
4617
- return { ok: false, reason: "no tmux pane bound for target" };
4618
- if (!(await tmuxPaneAlive(pane)))
4619
- return { ok: false, reason: "tmux pane no longer alive — the session needs /new or a respawn" };
4620
- const raw = await capturePaneTail(pane, Math.max(8, rows) + 12);
4621
- return { ok: true, pane: compactPane(raw, rows), busy: paneIsBusy(raw) };
4622
- };
4623
4637
  // Reading a peer's conversation is a transcript job, not a terminal job: the
4624
4638
  // jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
4625
- // and clipped at the viewport edge. Only `busy` still comes from the pane.
4639
+ // and clipped at the viewport edge. Text turns only, keepalive stripped. Only
4640
+ // `busy` still comes from the pane (the spinner is the one thing a transcript
4641
+ // cannot say during a long tool call); "where is it stuck" is answered by the
4642
+ // transcript too — `waiting` = tool calls still hanging on an idle session.
4626
4643
  const peekTurns = async (target, n = 6) => {
4627
4644
  const jsonl = jsonlOf(target);
4628
4645
  const busy = await isBusy(target);
4629
4646
  if (!jsonl || !existsSync(jsonl))
4630
- return { ok: false, reason: "no transcript bound for target", busy };
4631
- const dialog = renderDialog(tailTurns(jsonl, n));
4632
- return dialog ? { ok: true, dialog, busy } : { ok: false, reason: "transcript has no turns yet", busy };
4647
+ return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
4648
+ // `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
4649
+ // 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
4650
+ const rounds = talkRounds(jsonl, n, warmerSigs).slice(-n);
4651
+ const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
4652
+ const waiting = busy ? [] : openToolUses(jsonl);
4653
+ return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
4633
4654
  };
4634
4655
  // ── Prompt-cache keepalive ──────────────────────────────────────────
4635
4656
  // Anthropic's prompt cache expires ~5min after the last request. A pane that
@@ -4652,13 +4673,18 @@ export const startMirror = (deps) => {
4652
4673
  const initKeepalive = (jsonlPath, mtime, pingSigs) => {
4653
4674
  let lastMs = 0;
4654
4675
  let lastRealMs = 0;
4676
+ // round is seeded from the transcript too: starting at 0 would hand every
4677
+ // daemon reload a fresh budget, and a session idle across N reloads gets
4678
+ // N × rounds pings instead of rounds.
4679
+ let round = 0;
4655
4680
  try {
4656
4681
  const s = keepaliveStamps(jsonlPath, pingSigs);
4657
4682
  lastMs = s.lastMs;
4658
4683
  lastRealMs = s.lastRealMs;
4684
+ round = s.streak;
4659
4685
  }
4660
4686
  catch { /* unreadable tail */ }
4661
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round: 0, settledAt: 0 };
4687
+ return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0 };
4662
4688
  };
4663
4689
  const fireKeepalive = async (a, stalled) => {
4664
4690
  const kc = cfg.wrc.mirror.keepalive;
@@ -4803,11 +4829,12 @@ export const startMirror = (deps) => {
4803
4829
  continue; // cache already cold — a ping would cold-rewrite for nothing
4804
4830
  if (k.round >= kc.rounds)
4805
4831
  continue; // budget spent — let it go cold
4806
- // Stall recovery, decided by RULE only (no model self-judgment): the last
4807
- // transcript turn is a synthetic API-error/limit line, or the idle pane
4808
- // 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
4809
4836
  // instruction instead of the plain warmer.
4810
- const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
4837
+ const stalled = kc.resumeOnStall && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), idleTriggerMs, now);
4811
4838
  k.pinging = true;
4812
4839
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
4813
4840
  await fireKeepalive(a, stalled);
@@ -4963,7 +4990,6 @@ export const startMirror = (deps) => {
4963
4990
  worldPeers,
4964
4991
  resolvePeerTag,
4965
4992
  chatRoster,
4966
- peekPane,
4967
4993
  peekTurns,
4968
4994
  isBusy,
4969
4995
  setModel: async (target, wanted, scope) => {
@@ -4979,6 +5005,17 @@ export const startMirror = (deps) => {
4979
5005
  }
4980
5006
  return r;
4981
5007
  },
5008
+ transcripts: () => allTargets().flatMap((target) => {
5009
+ const jsonlPath = jsonlOf(target);
5010
+ if (!jsonlPath)
5011
+ return [];
5012
+ let mtime = 0;
5013
+ try {
5014
+ mtime = statSync(jsonlPath).mtimeMs;
5015
+ }
5016
+ catch { /* not written yet */ }
5017
+ return [{ target, jsonlPath, mtime }];
5018
+ }),
4982
5019
  lastText,
4983
5020
  lastReply: replySince,
4984
5021
  isOpenBubbleQuote,