wezard 2.2.2 → 2.4.0

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 (112) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/cli/init.js +2 -1
  5. package/dist/cli/init.js.map +1 -1
  6. package/dist/daemon/approval.js +90 -6
  7. package/dist/daemon/approval.js.map +1 -1
  8. package/dist/daemon/chat-log.js +6 -1
  9. package/dist/daemon/chat-log.js.map +1 -1
  10. package/dist/daemon/chat-name.js +15 -0
  11. package/dist/daemon/chat-name.js.map +1 -1
  12. package/dist/daemon/config-api.js +265 -70
  13. package/dist/daemon/config-api.js.map +1 -1
  14. package/dist/daemon/danger.js +15 -0
  15. package/dist/daemon/danger.js.map +1 -1
  16. package/dist/daemon/detail.js +49 -0
  17. package/dist/daemon/detail.js.map +1 -1
  18. package/dist/daemon/handoff.js +12 -5
  19. package/dist/daemon/handoff.js.map +1 -1
  20. package/dist/daemon/idle-nudge.js +55 -0
  21. package/dist/daemon/idle-nudge.js.map +1 -0
  22. package/dist/daemon/inbound.js +19 -14
  23. package/dist/daemon/inbound.js.map +1 -1
  24. package/dist/daemon/index.js +1509 -259
  25. package/dist/daemon/index.js.map +1 -1
  26. package/dist/daemon/jobs.js +180 -32
  27. package/dist/daemon/jobs.js.map +1 -1
  28. package/dist/daemon/last-response.js +15 -0
  29. package/dist/daemon/last-response.js.map +1 -1
  30. package/dist/daemon/memory-steward.js +124 -68
  31. package/dist/daemon/memory-steward.js.map +1 -1
  32. package/dist/daemon/mirror-bridge.js +506 -124
  33. package/dist/daemon/mirror-bridge.js.map +1 -1
  34. package/dist/daemon/mirror-store.js.map +1 -1
  35. package/dist/daemon/model-select.js +102 -0
  36. package/dist/daemon/model-select.js.map +1 -1
  37. package/dist/daemon/notices.js +147 -33
  38. package/dist/daemon/notices.js.map +1 -1
  39. package/dist/daemon/peers.js +261 -124
  40. package/dist/daemon/peers.js.map +1 -1
  41. package/dist/daemon/pending-items.js +171 -0
  42. package/dist/daemon/pending-items.js.map +1 -0
  43. package/dist/daemon/receipts.js +428 -80
  44. package/dist/daemon/receipts.js.map +1 -1
  45. package/dist/daemon/route.js +133 -19
  46. package/dist/daemon/route.js.map +1 -1
  47. package/dist/daemon/spawn-tmux.js +24 -13
  48. package/dist/daemon/spawn-tmux.js.map +1 -1
  49. package/dist/daemon/steward-guard.js +32 -0
  50. package/dist/daemon/steward-guard.js.map +1 -0
  51. package/dist/daemon/task-registry.js +1 -1
  52. package/dist/daemon/task-registry.js.map +1 -1
  53. package/dist/daemon/tasks.js +12 -4
  54. package/dist/daemon/tasks.js.map +1 -1
  55. package/dist/daemon/usage.js +4 -11
  56. package/dist/daemon/usage.js.map +1 -1
  57. package/dist/daemon/wizard-memory.js +36 -4
  58. package/dist/daemon/wizard-memory.js.map +1 -1
  59. package/dist/daemon/wizard.js +174 -87
  60. package/dist/daemon/wizard.js.map +1 -1
  61. package/dist/mcp/server.js +209 -132
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/shared/charter-view.js +35 -0
  64. package/dist/shared/charter-view.js.map +1 -0
  65. package/dist/shared/chat-http.js +134 -32
  66. package/dist/shared/chat-http.js.map +1 -1
  67. package/dist/shared/chat-render.js +6 -3
  68. package/dist/shared/chat-render.js.map +1 -1
  69. package/dist/shared/chat-view.js +17 -6
  70. package/dist/shared/chat-view.js.map +1 -1
  71. package/dist/shared/cli-backends.js +2 -0
  72. package/dist/shared/cli-backends.js.map +1 -1
  73. package/dist/shared/config-meta.js +91 -0
  74. package/dist/shared/config-meta.js.map +1 -0
  75. package/dist/shared/config-writer.js +5 -6
  76. package/dist/shared/config-writer.js.map +1 -1
  77. package/dist/shared/config.js +273 -294
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/daemon-token.js +18 -0
  80. package/dist/shared/daemon-token.js.map +1 -0
  81. package/dist/shared/detail-render.js +233 -20
  82. package/dist/shared/detail-render.js.map +1 -1
  83. package/dist/shared/detail-store.js +33 -12
  84. package/dist/shared/detail-store.js.map +1 -1
  85. package/dist/shared/effort.js +11 -0
  86. package/dist/shared/effort.js.map +1 -0
  87. package/dist/shared/keepalive.js +5 -5
  88. package/dist/shared/keepalive.js.map +1 -1
  89. package/dist/shared/model-prices.js +258 -0
  90. package/dist/shared/model-prices.js.map +1 -0
  91. package/dist/shared/pricing.js +55 -0
  92. package/dist/shared/pricing.js.map +1 -0
  93. package/dist/shared/reminder.js +18 -4
  94. package/dist/shared/reminder.js.map +1 -1
  95. package/dist/shared/role-render.js +210 -8
  96. package/dist/shared/role-render.js.map +1 -1
  97. package/dist/shared/role-view.js +104 -26
  98. package/dist/shared/role-view.js.map +1 -1
  99. package/dist/shared/session-label.js +4 -0
  100. package/dist/shared/session-label.js.map +1 -1
  101. package/dist/shared/task-file.js +16 -1
  102. package/dist/shared/task-file.js.map +1 -1
  103. package/dist/shared/turn-state.js +24 -0
  104. package/dist/shared/turn-state.js.map +1 -0
  105. package/dist/shared/world.js +116 -13
  106. package/dist/shared/world.js.map +1 -1
  107. package/dist/svr/index.js +2 -1
  108. package/dist/svr/index.js.map +1 -1
  109. package/hooks/pre-tool-use.sh +15 -3
  110. package/package.json +1 -1
  111. package/web/chat.css +226 -74
  112. package/web/chat.js +1075 -370
@@ -11,7 +11,8 @@
11
11
  // attachments) and the graph runner (which drives them) both compose on top.
12
12
  import { existsSync, openSync, readSync, closeSync, statSync } from "node:fs";
13
13
  import { backendForPath } from "../shared/cli-backends.js";
14
- import { truncate, truncateWithCount } from "../shared/std.js";
14
+ import { clipLine, truncate, truncateWithCount } from "../shared/std.js";
15
+ import { tildePath } from "./notices.js";
15
16
  import { isKeepalivePingText, withoutKeepalive } from "../shared/keepalive.js";
16
17
  import { envelopeAttrs, parseEnvelope, renderReminder } from "../shared/reminder.js";
17
18
  /** Strip ANSI SGR/CSI + OSC so captured pane text is safe to embed / match on. */
@@ -59,12 +60,39 @@ const readTailBytes = (jsonlPath, want) => {
59
60
  }
60
61
  }
61
62
  };
63
+ const HEAD_BYTES = 256 * 1024;
64
+ /** transcript 第一条带时间戳的记录的时刻 (ms) —— 会话真正开始的那一刻。只读文件头;
65
+ * 正文里的 `timestamp` 是转义过的 (`\"timestamp\"`), 正则不会误中。读不到 → undefined。 */
66
+ export const firstStamp = (jsonlPath) => {
67
+ if (!jsonlPath || !existsSync(jsonlPath))
68
+ return undefined;
69
+ let fd;
70
+ try {
71
+ const buf = Buffer.allocUnsafe(HEAD_BYTES);
72
+ fd = openSync(jsonlPath, "r");
73
+ const read = readSync(fd, buf, 0, HEAD_BYTES, 0);
74
+ const iso = /"timestamp":"([^"]+)"/.exec(buf.subarray(0, read).toString("utf8"))?.[1];
75
+ const t = iso ? Date.parse(iso) : NaN;
76
+ return Number.isFinite(t) ? t : undefined;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ finally {
82
+ if (fd !== undefined) {
83
+ try {
84
+ closeSync(fd);
85
+ }
86
+ catch { /* ignore */ }
87
+ }
88
+ }
89
+ };
62
90
  /** 从文件尾读一段并解析; `enough` 说不够就翻四倍重读, 直到够了、读完整个文件、
63
91
  * 或者撞上上限。窗口是手段不是目的 —— 调用方只说"我要几轮", 不该关心一条
64
92
  * attachment 有多大。 */
65
- const readTailUntil = (jsonlPath, parse, enough) => {
93
+ const readTailUntil = (jsonlPath, parse, enough, max = TAIL_BYTES_MAX) => {
66
94
  const size = fileSize(jsonlPath);
67
- const ceiling = Math.min(size || TAIL_BYTES, TAIL_BYTES_MAX);
95
+ const ceiling = Math.min(size || TAIL_BYTES, max);
68
96
  const walk = (win) => {
69
97
  const parsed = parse(readTailBytes(jsonlPath, win));
70
98
  return enough(parsed) || win >= ceiling ? parsed : walk(Math.min(win * 4, ceiling));
@@ -95,14 +123,21 @@ const blockText = (content) => {
95
123
  /** Last `n` user/assistant turns of a transcript, oldest first. Backend-agnostic:
96
124
  * the line shape is normalized through the owning CLI's dialect adapter. */
97
125
  export const tailTurns = (jsonlPath, n = 3, keepLines = false) => readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, keepLines), (ts) => ts.length >= n).slice(-n);
126
+ /** 最后一句进到模型的话 (带信封), 往回读到有为止 —— 中间隔着多少轮工具都不怕。 */
127
+ export const lastUserTurn = (jsonlPath) => readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, true), (ts) => ts.some((t) => t.role === "user"))
128
+ .filter((t) => t.role === "user").at(-1);
98
129
  /** `tailTurns` 去掉保温 ping/pong 之后的最后 `n` 轮 —— 凡是交给另一个 wizard (或人)
99
130
  * **读**的都走这里: 挂机一晚的会话尾巴上全是 ping/pong, 照 tailTurns 数出来的
100
- * 「最近 6 轮」一句真话都没有, 「最后一条回复」是一个 pong。`pingSigs` 只给保温
101
- * 那一种 —— resumePing ("continue") 之后模型是真的在干活, 那一轮不能丢。 */
102
- export const talkTurns = (jsonlPath, n, pingSigs = [], keepLines = false) => {
131
+ * 「最近 6 轮」一句真话都没有, 「最后一条回复」是一个 pong。 */
132
+ export const talkTurns = (jsonlPath, n, pingSigs = [], keepLines = false, marks = false) => {
103
133
  const clean = (ts) => withoutKeepalive(ts, pingSigs);
104
- return clean(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, keepLines), (ts) => clean(ts).length >= n)).slice(-n);
134
+ return clean(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, keepLines, marks), (ts) => clean(ts).length >= n)).slice(-n);
105
135
  };
136
+ /** 从 `sinceMs` 起的全部来回 (带标记): 往回读到越过它的第一行为止, **不受 TAIL_BYTES_MAX
137
+ * 限制**。按条数读尾巴的 talkTurns 管不了一轮很长的情形: 一轮里的大段工具输出 (读文件、
138
+ * 跑命令) 能把开这一轮的那句推到 2MB 之外 —— 回执就再也定位不到问话, 对方答完了也只能
139
+ * 报 silent。问话一定晚于发话时刻, 所以读到发话时刻之前就够了, 再大的文件也只读这一段。 */
140
+ export const turnsSince = (jsonlPath, sinceMs, pingSigs = []) => withoutKeepalive(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, true, true), (ts) => ts.some((t) => !!t.ms && t.ms < sinceMs), Infinity), pingSigs);
106
141
  /** 一个会话的来回, 每个来回 = 一句问话 + 它之后的全部回答 (途中的话在前, 终句在
107
142
  * 最后), 旧的在前。读多深由时间窗定, 调用方再按时刻与条数裁:
108
143
  * 给了 `since` → 一直读到比它更早的那一句 (窗口内的一句不漏);
@@ -130,8 +165,8 @@ export const talkRounds = (jsonlPath, rounds, pingSigs = [], win = {}) => {
130
165
  };
131
166
  /** 纯解析: 一段 transcript 原文 → 里面的对话轮次。`keepLines` 保留换行 —— 摘要与
132
167
  * 预览要压成一行, 但交给另一个 wizard 读的回复不能: 表格 / 列表 / 代码块压成一行
133
- * 之后对模型就是一团字。 */
134
- const parseTurns = (jsonlPath, raw, keepLines = false) => {
168
+ * 之后对模型就是一团字。`marks` 把 CLI 自插的 user 行留成空的 `notice` 轮 (见 Turn)。 */
169
+ const parseTurns = (jsonlPath, raw, keepLines = false, marks = false) => {
135
170
  if (!raw)
136
171
  return [];
137
172
  const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
@@ -148,6 +183,12 @@ const parseTurns = (jsonlPath, raw, keepLines = false) => {
148
183
  }
149
184
  // The first line of a truncated tail is usually a fragment — normalize
150
185
  // returning null (or a throw) is the expected outcome, not an error.
186
+ const att = parsed.attachment;
187
+ if (marks && att?.type === "queued_command" && typeof att.prompt === "string" && !parsed.isSidechain) {
188
+ const env = parseEnvelope(att.prompt);
189
+ const ms = Date.parse(String(parsed.timestamp ?? ""));
190
+ return env ? [{ role: "user", text: "", ms, env, queued: true }] : [];
191
+ }
151
192
  let row;
152
193
  try {
153
194
  row = normalize(parsed);
@@ -170,10 +211,12 @@ const parseTurns = (jsonlPath, raw, keepLines = false) => {
170
211
  // keepaliveStamps to mtime fallback, and let the keepalive's own ping
171
212
  // writes (plus snapshot/summary churn) read as REAL activity that reset
172
213
  // the round budget without bound.
173
- const rawTs = parsed.timestamp;
174
- const ms = typeof rawTs === "number" ? rawTs : Date.parse(String(rawTs ?? ""));
214
+ const ms = stampMs(parsed.timestamp);
175
215
  const mid = role === "assistant" && row.message?.stop_reason === "tool_use";
176
- return text ? [{ role, text, ms: Number.isNaN(ms) ? 0 : ms, ...(env ? { env } : {}), ...(mid ? { mid: true } : {}) }] : [];
216
+ if (!text && marks && role === "user" && raw.includes("<task-notification"))
217
+ return [{ role, text: "", ms, notice: true }];
218
+ const apiError = role === "assistant" && parsed.isApiErrorMessage === true;
219
+ return text ? [{ role, text, ms, ...(env ? { env } : {}), ...(mid ? { mid: true } : {}), ...(apiError ? { apiError: true } : {}) }] : [];
177
220
  });
178
221
  };
179
222
  // Flatten a message's content for QUOTE DEDUP only — unlike `blockText` this
@@ -360,12 +403,29 @@ const parseOpenTools = (jsonlPath, raw) => {
360
403
  * `RESULT: …`, 这里把它捞出来。取**最后一个**匹配 —— 中间复述过这个格式的那些
361
404
  * 不算数。捞不到返回 "", 调用方退回整段 lastText, 所以不遵守约定也只是退化。 */
362
405
  export const extractResult = (text, max = 800) => {
406
+ const c = parseClosing(text, max);
407
+ return c.kind === "result" ? c.text : "";
408
+ };
409
+ // NEED 只认全大写: "what I need: …" / 「需要: …」在普通答话里太常见, 误认一次就是
410
+ // 一份不计数的回执。
411
+ const CLOSING_RE = /(?:^|[\s>*|-])(RESULT|Result|result|结论|NEED)\s*[::]\s*/gm;
412
+ const ARTIFACT_RE = /^[ \t>*|-]*ARTIFACT[ \t]*[::][ \t]*(\S+)(?:[ \t]+(?:—|--|-|:)[ \t]*(.*))?$/gm;
413
+ export const parseClosing = (text, max = 800) => {
414
+ const artifacts = [...text.matchAll(ARTIFACT_RE)]
415
+ .map((m) => ({ path: m[1].replace(/^`|`$/g, ""), note: (m[2] ?? "").trim() }))
416
+ .filter((a) => !/^<.*>$/.test(a.path)); // 复述格式时的 `<路径>` 占位
363
417
  // 标记之后的**全部**内容, 而不是"那一行" —— 结论常常不止一行 (路径一行、说明
364
418
  // 一行)。约定是收口在最后, 所以标记之后剩下的就是结论。取最后一个匹配: 模型常
365
- // 先复述一遍格式要求再给答案。
366
- const hits = [...text.matchAll(/(?:^|[\s>*|-])(?:RESULT|结论)\s*[::]\s*/gim)];
367
- const last = hits[hits.length - 1];
368
- return last ? text.slice((last.index ?? 0) + last[0].length).trim().slice(0, max) : "";
419
+ // 先复述一遍格式要求再给答案。ARTIFACT 行不算结论正文。
420
+ const after = (m) => text.slice((m.index ?? 0) + m[0].length).replace(ARTIFACT_RE, "").trim().slice(0, max);
421
+ const marks = [...text.matchAll(CLOSING_RE)];
422
+ // 交差优先: 写了 RESULT 就是交差, 哪怕后面跟一句 `NEED: 无` —— 当成反问, 工单就卡到超时。
423
+ const result = marks.filter((m) => m[1] !== "NEED").at(-1);
424
+ if (result)
425
+ return { kind: "result", text: after(result), artifacts };
426
+ const need = marks.at(-1);
427
+ const q = need ? after(need) : "";
428
+ return q && !/^(?:无|没有|none|n\/a|-+)[。.]?$/i.test(q) ? { kind: "need", text: q, artifacts } : { kind: "none", text: "", artifacts };
369
429
  };
370
430
  /** The peer's most recent assistant message — the handoff payload when one
371
431
  * agent drives another ("take #fix's conclusion and review it"). */
@@ -380,9 +440,11 @@ export const clipMiddle = (s, max = 4000, head = Math.floor(max / 4)) => s.lengt
380
440
  * 发话时刻是最便宜的关联 id (A2A 的 taskId 在这里的等价物): 早于它的一律不算。
381
441
  * 没有时间戳的行 (ms=0) 无从判断, 放行 —— 退化成旧行为, 而不是把回复吞掉。
382
442
  * 保温的 pong 不算回复: 它比真正的答案晚, 不剔掉的话等的人拿到的就是一个 "pong"。 */
383
- export const lastReply = (jsonlPath, sinceMs = 0, pingSigs = []) => talkTurns(jsonlPath, 40, pingSigs, true)
443
+ export const lastReply = (jsonlPath, sinceMs = 0, pingSigs = []) => talkTurns(jsonlPath, 40, pingSigs, true, true)
384
444
  .filter((t) => t.role === "assistant" && (!t.ms || t.ms >= sinceMs))
385
445
  .at(-1)?.text ?? "";
446
+ /** 报错之后叫它接着干的那一句 (`continue` / `继续` …)。 */
447
+ const RESUME_RE = /^(?:continue|go on|resume|继续|接着(?:干|做)?)[\s.!。!]*$/i;
386
448
  /** paste 到那一行落盘之间的抖动 —— 问话的时间戳可能比我们记的发话时刻早一点。 */
387
449
  const ASK_SLACK_MS = 60_000;
388
450
  /** 「`from` 那一句的回答」—— 比 lastReply 严一档, 自动回执靠它定位。
@@ -390,44 +452,45 @@ const ASK_SLACK_MS = 60_000;
390
452
  * 发话时刻 (lastReply 的 sinceMs) 分不开两件事: 目标正忙时我们这一句是排队的,
391
453
  * 它先吐出来的是**上一件事**的结论, 而那条回复同样晚于我们 paste 的时刻 —— 按时刻
392
454
  * 取就会拿旧结论当新回执。信封是更硬的锚: 我们注入的那一句在它的 transcript 里挂着
393
- * `kind=peer from=.我`, 从那一句往后数, 答的就一定是我们问的。找不到那一句 (老 wizard 的 MCP
394
- * 还不挂信封 / 后端不写时间戳) 返回 `undefined`, 调用方退回 lastReply —— 退化而不是
395
- * 吞掉; 找到了问话但它后面还没有回答 (我们那一句还排在队里) 返回 "" —— 调用方必须
396
- * 接着等, 绝不能退回去把上一件事的结论当成这一次的回执。 */
397
- export const replyToPeer = (jsonlPath, fromName, sinceMs = 0, pingSigs = []) => {
398
- const r = answerOf(talkTurns(jsonlPath, 80, pingSigs, true), fromName, sinceMs);
399
- // 两种"没有"必须分开: 找不到那一句问话 (老 wizard 不挂信封) → undefined, 调用方
400
- // 退回按时刻取; 找到了问话但它后面还没有回答 (我们这一句还排在队里) → "",
401
- // 调用方必须继续等, 绝不能退回去把**上一件事**的结论当成这一次的回执。
402
- return r && r.text;
455
+ * `kind=peer from=.我`, 从那一句往后数, 答的就一定是我们问的。找不到那一句 (还排在
456
+ * 输入框里没读进) 返回 `undefined`; 找到了问话但它后面还没有回答返回 "" —— 两种都要
457
+ * 接着等, 绝不能退回按时刻取把上一件事的结论当成这一次的回执 (receipts 只用前者分辨
458
+ * 「它在忙前面的轮」与「读进了却没答」)。 */
459
+ /** 找问话的那一段: 有发话时刻就读到它之前 (问话可以离尾巴很远), 没有 (老调用) 退回最近 80 条。 */
460
+ const askWindow = (jsonlPath, sinceMs, pingSigs) => sinceMs > 0 ? turnsSince(jsonlPath, sinceMs - ASK_SLACK_MS, pingSigs) : talkTurns(jsonlPath, 80, pingSigs, true, true);
461
+ export const replyToPeer = (jsonlPath, fromName, sinceMs = 0, pingSigs = [], turn) => {
462
+ const r = answerOf(askWindow(jsonlPath, sinceMs, pingSigs), fromName, sinceMs, turn);
463
+ return r && { text: r.text, ...(r.error ? { error: true } : {}) };
403
464
  };
404
465
  /** 交接那一刻, `from` 那一句在旧会话里**收口**了没有 —— 比 replyToPeer 再严一档。
405
466
  *
406
467
  * 自我交接是在某一轮**中途**调的: 那一轮最后一条 assistant 是"已交接"之类的收尾,
407
468
  * 不是答案。所以只认在 `untilMs` 之前就被下一句 user 行关上的那一轮: 问话那一轮
408
469
  * 还开着 (就是交接这一轮) → "", 这份回执的义务跟着简报转进新会话。三态同 replyToPeer。 */
409
- export const replyClosedBefore = (jsonlPath, fromName, sinceMs, untilMs, pingSigs = []) => {
410
- const r = answerOf(talkTurns(jsonlPath, 80, pingSigs, true), fromName, sinceMs);
411
- return r && (r.closedAt !== undefined && r.closedAt < untilMs ? r.text : "");
412
- };
413
- /** `from` 那一句 (发话时刻之后最新的那次) 所在那一轮的终句。答案止于**下一句 user 行**:
414
- * 这一轮还开着、最后一句又是中途句 (`mid`, 后面跟着工具调用) → 还没答完, 回 "" 让调用方
415
- * 接着等 —— 忙闲判定会在长工具调用 / 生成的间隙误报一次「闲了」, 那时取到的是
416
- * 「开始读文件」这种开场白, 不是结论。被下一句 user 行关上的就照取最后一句 (中途被打断)。
417
- * 那是另一轮的开始 (同伴的新话、回执、人) —— 不截的话, 答方刚说完紧接着收到一份回执、
418
- * 回了句「收到」, 发话方拿到的就是这句。CLI 在一轮中途吃进的排队消息记成
419
- * `attachment`, 不是 user 行, 截不断一轮。`closedAt` = 关上这一轮的那一行的时刻
420
- * (还开着 = undefined)。 */
421
- const answerOf = (ts, fromName, sinceMs) => {
422
- const asked = ts.reduce((hit, t, i) => t.role === "user" && t.env?.kind === "peer" && t.env.from === fromName && (!t.ms || t.ms >= sinceMs - ASK_SLACK_MS)
423
- ? i
424
- : hit, -1);
470
+ export const replyClosedBefore = (jsonlPath, fromName, sinceMs, untilMs, pingSigs = [], turn) => {
471
+ const r = answerOf(askWindow(jsonlPath, sinceMs, pingSigs), fromName, sinceMs, turn);
472
+ return r && (r.closedAt !== undefined && r.closedAt < untilMs && !r.error ? r.text : "");
473
+ };
474
+ /** 这一行是在续上一轮 (报错之后没有信封的 `continue` / 「继续」), 不起新的一轮。 */
475
+ const resumesAt = (ts, i) => !!ts[i - 1]?.apiError && !ts[i].env && !ts[i].notice && RESUME_RE.test(ts[i].text.trim());
476
+ /** 开最近这一轮的那句 user 行 (`marks` 读出来的 turns): 排队吃进的、后台通知、报错后的
477
+ * 续跑都不算 —— 它们不起新的一轮, 这一轮是谁发起的要看再往前那一句。 */
478
+ export const openingOf = (ts) => ts.filter((t, i) => t.role === "user" && !t.queued && !t.notice && !resumesAt(ts, i)).at(-1);
479
+ /** 开最近这一轮的那句 user 行, 往回读到有为止 (不受 TAIL_BYTES_MAX 限制) —— 一轮再长,
480
+ * 「这一轮是谁发起的」也要认得出 (父 k、链头、打断会不会误伤别人的活都靠它)。 */
481
+ export const openingTurn = (jsonlPath, pingSigs = []) => openingOf(withoutKeepalive(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, false, true), (ts) => !!openingOf(withoutKeepalive(ts, pingSigs)), Infinity), pingSigs));
482
+ const answerOf = (ts, fromName, sinceMs, turn) => {
483
+ const lastAsk = (hit) => ts.reduce((at, t, i) => (t.role === "user" && t.env && hit(t.env) && (!t.ms || t.ms >= sinceMs - ASK_SLACK_MS) ? i : at), -1);
484
+ const byTurn = turn ? lastAsk((e) => !e.receipt && e.turn === turn) : -1;
485
+ // 退回按人找时只认没有件号的老信封: 带件号的那些是别的件, 不是这一件。
486
+ const direct = byTurn >= 0 ? byTurn : lastAsk((e) => !e.receipt && e.kind === "peer" && e.from === fromName && !(turn && e.turn));
487
+ const asked = Math.max(direct, lastAsk((e) => !!e.receipt && e.replyTo === fromName));
425
488
  if (asked < 0)
426
489
  return undefined;
427
- const next = ts.findIndex((t, i) => i > asked && t.role === "user");
490
+ const next = ts.findIndex((t, i) => i > asked && t.role === "user" && !t.queued && !resumesAt(ts, i));
428
491
  const last = ts.slice(asked + 1, next < 0 ? undefined : next).filter((t) => t.role === "assistant").at(-1);
429
492
  const text = last && !(last.mid && next < 0) ? last.text : "";
430
- return { text, ...(next < 0 ? {} : { closedAt: ts[next].ms ?? 0 }) };
493
+ return { text, ...(next < 0 ? {} : { closedAt: ts[next].ms ?? 0 }), ...(text && last?.apiError ? { error: true } : {}) };
431
494
  };
432
495
  // ── wizard → wizard 的信封 ────────────────────────────────────────────
433
496
  // 同伴的话是原样落进输入框的, 而落进输入框的东西在模型眼里都是「用户说的」。没有
@@ -448,40 +511,89 @@ export { parseEnvelope };
448
511
  * 谁、在哪说的; 住在与一个人的单聊里的, 默认值就是对的, 不为它每轮多付一段。 */
449
512
  export const renderHumanEnvelope = (user, chat) => renderReminder(envelopeAttrs.human(user, chat), [`这一轮是 \`${user}\` 在群 **${chat}** 里说的, 你的回复发回那个群。`]);
450
513
  /** 定时任务放的那一轮: 不标的话, 记录里它就成了「人说的」。 */
451
- export const renderTaskEnvelope = (taskId) => renderReminder(envelopeAttrs.task(taskId), [`这一轮是定时任务 \`${taskId}\` 到点放进来的, 不是人此刻说的 —— 照常执行, 回复照常发进群。`]);
514
+ export const renderTaskEnvelope = (taskId, quiet) => renderReminder(envelopeAttrs.task(taskId, quiet?.turn), quiet
515
+ ? [`这一轮是定时任务 \`${taskId}\` 到点放进来的, 不是人此刻说的, 而且是**有事才说**的那种: 这一轮私下跑, 没人在看。`,
516
+ "照常执行。看完**没有值得人知道的**, 最后一条消息就只写一行 `QUIET`, 什么也不会发出去; 有, 就把最后一条消息写给人看 —— 守护进程原样转进群。",
517
+ "要派活给别的 wizard, 就在这一轮里用 `wait_peer` 把结果拿回来再收口 —— 回执要等这一轮结束才到, 那时已经没人替你转发。"]
518
+ : [`这一轮是定时任务 \`${taskId}\` 到点放进来的, 不是人此刻说的 —— 照常执行, 回复照常发进群。`]);
452
519
  /** `from` = 发话方的称呼 (`.name`); `chat` 给了 = 公开轮 (那个群的名字, 可以是 ""),
453
520
  * 不给 = 私聊。 */
454
- export const renderPeerEnvelope = (from, chat) => renderReminder(envelopeAttrs.peer(from, chat), chat === undefined
455
- ? [
456
- `这一轮是 wizard \`${from}\` 发来的**私聊**, 不是人说的: 人看不见这一轮, 读你回复的是 \`${from}\`。`,
457
- `你这一轮的**最后一条消息**就是给它的回执 —— 你一停下, 守护进程自动把它送进 \`${from}\` 的输入框, **你不需要再调任何工具**: 不要 send_peer 回它 (那会让它多跑一轮), 也不要为这一轮 notify。`,
458
- "对 wizard 直说: 不寒暄、不加对人的称呼、不复述它的话; 结论收口成末尾的 `RESULT: …` (交付物写进文件就回传路径), 要它补信息也写在那里。",
459
- ]
460
- : [
461
- `这一轮是 wizard \`${from}\` 在群${chat ? ` **${chat}** ` : ""}里**公开**对你说的: 你的回复会直接发进那个群, 人和 \`${from}\` 都看得到。`,
462
- `照对人说话的方式答; 不要再 send_peer 把同一段话回给 \`${from}\` —— 你停下之后守护进程会自动把这段结论送回它那里。`,
463
- ]);
521
+ export const renderPeerEnvelope = (from, chat, t) => renderReminder(envelopeAttrs.peer(from, chat, t), [
522
+ ...(chat === undefined
523
+ ? [
524
+ `这一轮是 wizard \`${from}\` 发来的**私聊**, 不是人说的: 人看不见这一轮, 读你回复的是 \`${from}\`。`,
525
+ ...(t?.quiet
526
+ ? [`\`${from}\` **不收回执**: 你这一轮的终句不会送到任何地方 —— 别 tell_peer 回它, 也别为这一轮 notify。`]
527
+ : [
528
+ `你这一轮的**最后一条消息**就是给它的回执 —— 你一停下, 守护进程自动把它送进 \`${from}\` 的输入框, **你不需要再调任何工具**: 不要 send_peer 回它 (那会让它多跑一轮), 也不要为这一轮 notify。`,
529
+ "对 wizard 直说: 不寒暄、不加对人的称呼、不复述它的话; 结论收口成末尾的 `RESULT: …` (交付物写进文件就回传路径); 要它补信息才能干下去, 就收口成 `NEED: <问题>` (这一份不算交差); 交付了文件, 每个加一行 `ARTIFACT: <路径> — 一句话`。",
530
+ ]),
531
+ ]
532
+ : [
533
+ `这一轮是 wizard \`${from}\` 在群${chat ? ` **${chat}** ` : ""}里**公开**对你说的: 你的回复会直接发进那个群, 人和 \`${from}\` 都看得到。`,
534
+ `照对人说话的方式答; 不要再 send_peer 把同一段话回给 \`${from}\` —— 你停下之后守护进程会自动把这段结论送回它那里。`,
535
+ ]),
536
+ ...(t?.re ? [`这是对件 \`${t.turn}\` 的**续问**: 接着那件活说, 不是一件新活。`] : []),
537
+ // 这句话要怎么接 —— 只有发话方知道, 不写出来对方一律当成一件活去干。
538
+ ...(t?.act === "ask" ? [`这是一个**提问**: 只答这一问, 几句话${t.quiet ? "" : "收口 (`RESULT: <答案>`)"}, 别为它开新活; 你手上原来的活答完接着干。`] : []),
539
+ ...(t?.act === "fyi" ? ["这是一条**知会**: 不用回复、不用收口, 也别为它开新活 —— 与手头的活相关就顺手吸收, 无关就略过; 一句话收住这一轮即可。"] : []),
540
+ // 来回多了就收口: 不在守护进程里拒绝 —— 拒了模型会换条通路接着乒乓。
541
+ ...((t?.legs ?? 0) > 3 ? [`这件活已经来回 ${t.legs - 1} 次: 这一次请直接给 \`RESULT:\`, 拿不准的写清你的假设。`] : []),
542
+ ...(t?.deadline ? [`期限 ${hhmm(t.deadline)}: 到点还没答完, \`${from}\` 收到的是一份超时回执。`] : []),
543
+ ]);
544
+ const hhmm = (ms) => new Date(ms).toTimeString().slice(0, 5);
464
545
  /** 回执的信封 —— 这一轮是对方干完活、守护进程自动送回来的结论, 不是新派的活。
465
546
  *
466
547
  * 为什么必须有它: 回执是原样 paste 进输入框的, 而落进输入框的东西在模型眼里都是
467
548
  * 「有人在对我说话」。没有这一段, 它会把一份结论当成一件新活去做, 或者客气地回它
468
549
  * 一句 —— 而回那一句又会被对方当成新的一轮。所以这里写死三件事: 这是回执、原话在
469
550
  * 下面、读完接着干你自己的, 别回话。 */
470
- export const renderReceiptEnvelope = (from, chat, job) => renderReminder(envelopeAttrs.receipt(from, chat, job), [
471
- `这是 wizard \`${from}\` 对你某一次 send_peer 的**回执**: 它那一轮干完了, 守护进程把它的结论自动送到你这里 —— 不是人说的, 也不是新派给你的活。`,
472
- ...(chat === undefined
473
- ? [`这段对话是私聊, 人看不见。`]
474
- : [`这一轮的对话在群${chat ? ` **${chat}** ` : ""}里, 人看得见你们俩。`]),
475
- "下面就是它的原话。据此接着干你自己手上那件事; **不要为收到回执而回话** —— 不要 send_peer 回它 (除非你确实有新的东西要问它), 也不要把这段原话复述给人, 只说你据此做了什么、结论是什么。",
476
- "它可能还没干完全部 —— 读 `RESULT:` 那一行 (如果有) 作为它的收口结论; 要更多细节用 peek_peer 读它的对话, 不要猜。",
551
+ export const renderReceiptEnvelope = (from, chat, job, status = "done", turn, route) => renderReminder(envelopeAttrs.receipt(from, chat, job, status, turn, route), [
552
+ ...(status === "done"
553
+ ? [`这是 wizard \`${from}\` 对你某一次 send_peer 的**回执**: 它那一轮干完了, 守护进程把它的结论自动送到你这里 —— 不是人说的, 也不是新派给你的活。`]
554
+ : status === "need"
555
+ ? [`这是 wizard \`${from}\` 对你某一次 send_peer 的**反问**: 它缺信息干不下去, 下面 \`NEED:\` 后面就是它要的 —— 按下面的件号续问答它; 这一份**不计入工单**, 它拿到答复干完会再送一份回执。`]
556
+ : status === "error"
557
+ ? [`这是 wizard \`${from}\` 对你某一次 send_peer 的**失败回执**: 它那一轮以 CLI 报错收尾, 下面是报错原文, 不是它的结论, **不计入工单**。它若被续跑、之后答出来, 结论会照常再送来一份; 等不及就 peek_peer 看它, 或者再 tell_peer 它一次。`]
558
+ : [`这是 wizard \`${from}\` 对你某一次 send_peer 的**失败回执**: ${FAILED[status]}, 这一份不会再有答案了 (工单里按已落定计)。自己判断: 换人、再 tell_peer 追问, 或者在汇总里如实写缺了这一份。下面是守护进程的说明和它最后说的一句。`]),
559
+ // 去向由守护进程按派活链算好 (receipts.routeOf), 这里只告诉模型终句会落到哪。
560
+ ...(route?.replyTo?.startsWith(".")
561
+ ? [`你这一轮的**最后一条消息会作为回执送回 \`${route.replyTo}\`** (它当初把这件活派给你, 你转给了 \`${from}\`) —— 据此收口, 末尾写 \`RESULT: …\`。`]
562
+ : route?.replyTo
563
+ ? [`你这一轮的**最后一条消息会发进群「${route.replyTo}」**, ${route.audience ? `\`${route.audience}\` ` : "人"}在等这件事的结论 —— 写给人看: 做成了什么、没做成什么, 不复述 \`${from}\` 的原话。`]
564
+ : route?.pending && !(job && job.job)
565
+ ? [`还有 ${route.pending} 份没回: 这一轮的回复只记在 rolepage, 不外发 —— 先记下这一份, 等最后一份到了再收口。`]
566
+ : chat === undefined
567
+ ? [`这段对话是私聊, 人看不见。`]
568
+ : [`这一轮的对话在群${chat ? ` **${chat}** ` : ""}里, 人看得见你们俩。`]),
569
+ ...(status === "done"
570
+ ? [
571
+ ...(route?.replyTo ? [] : ["下面就是它的原话。据此接着干你自己手上那件事; **不要为收到回执而回话** —— 不要 send_peer 回它 (除非你确实有新的东西要问它), 也不要把这段原话复述给人, 只说你据此做了什么、结论是什么。"]),
572
+ "它可能还没干完全部 —— 读 `RESULT:` 那一行 (如果有) 作为它的收口结论; 要更多细节用 peek_peer 读它的对话, 不要猜。",
573
+ ]
574
+ : []),
575
+ ...(turn ? [`这件活是 \`${turn}\`: 要就它追问、补充, 用 \`tell_peer({name: "${from}", re: "${turn}"})\` —— 沿用同一个频道, 工单还开着就沿用工单。`] : []),
477
576
  // 「齐了吗」是守护进程数的, 不让模型在上下文里自己数 —— 异步回执是 N 个独立
478
577
  // 的轮次陆续进来的, 数错一个就会提前收口或者永远等。
479
- ...(job && job.job
480
- ? job.done >= job.total
481
- ? [`这是工单 \`${job.job}\` 的**最后一份** (${job.done}/${job.total}, 全部到齐): 现在可以汇总收口了 —— close_job(summary) 发结论并回收临时分身。`]
482
- : [`这是工单 \`${job.job}\` 的第 ${job.done}/${job.total} 份, **还差 ${job.total - job.done} 份**: 先把这一份记住 (或落到文件里), 不要现在汇总、也不要向人汇报进度; 等最后一份到了会明确告诉你「全部到齐」。`]
578
+ ...(job && job.job && status !== "need" && !(job.req && status === "error")
579
+ ? job.closed
580
+ ? [`工单 \`${job.job}\` 已经收工 (close_job 过了), 这一份是收工之后才到的: 不必再 close_job, 也不计入那次汇总${status === "done" && !route?.replyTo ? " —— 这段结论还有用, 就自己补一句交代 (notify 给人 / 回给上游)" : ""}。`]
581
+ : job.req
582
+ // 需求根单: 不走「汇总 → close_job」—— 关单 = 归档, 要等人验收。
583
+ ? [status === "done"
584
+ ? `这是需求根单 \`${job.job}\` 的交付: lead 交差了。向人交代结果后**别 close_job**, 等人验收 —— 认可 → \`close_job({job: "${job.job}"})\` 归档并回收 lead; 说不对 → \`tell_peer({name: "${from}", re: "${turn}", job: "${job.job}"})\` 返工 (同一张单)。对人别提单号。`
585
+ : `这是需求根单 \`${job.job}\`, 但 lead 没交差 (${status}): 向人说明卡在哪。lead 还在 → \`tell_peer({name: "${from}", re: "${turn}", job: "${job.job}"})\` 叫它续; 否则 \`close_job({job: "${job.job}"})\` 关掉, 需要的话重新 \`dispatch({lead:true})\` 开新单。对人别提单号。`]
586
+ : job.done >= job.total
587
+ ? [`这是工单 \`${job.job}\` 的**最后一份** (${job.done}/${job.total}, 全部到齐): 现在可以汇总收口了 —— close_job(summary) 留档并回收临时分身 (不发群); 给人的结论就是你这一轮的最后一条消息, 用你自己的话写${route?.replyTo && !route.replyTo.startsWith(".") ? " —— 它会进群" : ""}。`]
588
+ : [`这是工单 \`${job.job}\` 的第 ${job.done}/${job.total} 份, **还差 ${job.total - job.done} 份**: 先把这一份记住 (或落到文件里), 不要现在汇总、也不要向人汇报进度; 等最后一份到了会明确告诉你「全部到齐」。`]
483
589
  : []),
484
590
  ]);
591
+ const FAILED = {
592
+ timeout: "等到期限它还没答完",
593
+ silent: "它停下了几次, 都没有答这一句",
594
+ dead: "它的 pane 没了 (被收掉或崩了)",
595
+ canceled: "它这件活被人收掉 / 打断了",
596
+ };
485
597
  /** Prompt-token size of the session's most recent turn: input + both cache
486
598
  * tiers = how full the context window is, i.e. exactly what a cold cache would
487
599
  * have to re-write at 1.25x. Read from the last assistant usage snapshot in
@@ -517,6 +629,66 @@ const pickContextTokens = (jsonlPath, raw) => {
517
629
  }
518
630
  return 0;
519
631
  };
632
+ /** Prompt-cache TTL (s) this session actually writes with, read off the last
633
+ * usage's per-tier split (`cache_creation.ephemeral_{1h,5m}_input_tokens`):
634
+ * Claude Code on a subscription writes 1h entries, the bare API default is 5min.
635
+ * 0 = no tiered write on record (backend without the split, or no turn yet) —
636
+ * the caller falls back to config. */
637
+ export const cacheTtlSec = (jsonlPath) => readTailUntil(jsonlPath, (raw) => pickCacheTtl(jsonlPath, raw), (v) => v > 0);
638
+ const pickCacheTtl = (jsonlPath, raw) => {
639
+ const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
640
+ const lines = raw.split("\n").filter((l) => l.includes("ephemeral_"));
641
+ for (let i = lines.length - 1; i >= 0; i--) {
642
+ let row;
643
+ try {
644
+ row = normalize(JSON.parse(lines[i]));
645
+ }
646
+ catch {
647
+ continue;
648
+ }
649
+ const cc = row?.message?.usage?.cache_creation;
650
+ if ((cc?.ephemeral_1h_input_tokens ?? 0) > 0)
651
+ return 3600;
652
+ if ((cc?.ephemeral_5m_input_tokens ?? 0) > 0)
653
+ return 300;
654
+ }
655
+ return 0;
656
+ };
657
+ /** When this session's prompt cache was last touched: the START of its newest answered
658
+ * API request (= the user / tool_result row it was sent after), since TTL runs from
659
+ * the start of the request that reads or writes the entry. File mtime marks the
660
+ * END — a minute later at p99, several on a long generation — and also moves on
661
+ * metadata rows that touch no cache. Synthetic replies (`<synthetic>`, all-zero
662
+ * usage) sent nothing. A request still in flight is not counted (its user row may be a
663
+ * local command that sends nothing) — older = colder, the safe side.
664
+ * 0 = no request on record — the caller falls back to mtime. */
665
+ export const cacheTouchMs = (jsonlPath) => readTailUntil(jsonlPath, (raw) => pickCacheTouch(jsonlPath, raw), (v) => v > 0);
666
+ const pickCacheTouch = (jsonlPath, raw) => {
667
+ const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
668
+ return raw.split("\n").reduce((acc, line) => {
669
+ let parsed;
670
+ try {
671
+ parsed = JSON.parse(line);
672
+ }
673
+ catch {
674
+ return acc;
675
+ }
676
+ const row = normalize(parsed);
677
+ if (!row || row.isSidechain)
678
+ return acc;
679
+ const ms = stampMs(parsed.timestamp);
680
+ if (row.type === "user")
681
+ return ms ? { ...acc, sentAt: ms } : acc;
682
+ const u = row.type === "assistant" ? row.message?.usage : undefined;
683
+ const sent = (u?.input_tokens ?? 0) + (u?.cache_read_input_tokens ?? 0) + (u?.cache_creation_input_tokens ?? 0) > 0;
684
+ return sent ? { ...acc, touch: acc.sentAt || ms } : acc;
685
+ }, { sentAt: 0, touch: 0 }).touch;
686
+ };
687
+ /** A row's `timestamp` as epoch ms (Claude: ISO string, CodeBuddy: number); 0 if absent / unparsable. */
688
+ const stampMs = (ts) => {
689
+ const ms = typeof ts === "number" ? ts : Date.parse(String(ts ?? ""));
690
+ return Number.isNaN(ms) ? 0 : ms;
691
+ };
520
692
  /** 这段上下文里读过 / 改过的文件 (旧的在前、去重, 同一文件按最后一次碰它的位置排)。
521
693
  *
522
694
  * 「它的上下文里装着什么」是可以算出来的事实: 从文件尾往回, 撞上 compact 边界就停
@@ -616,8 +788,8 @@ const lastToolMs = (jsonlPath) => {
616
788
  return m;
617
789
  const content = row.message?.content;
618
790
  const tooly = Array.isArray(content) && content.some((b) => /^tool_(use|result)$/.test(b?.type ?? ""));
619
- const ms = typeof parsed.timestamp === "number" ? parsed.timestamp : Date.parse(String(parsed.timestamp ?? ""));
620
- return tooly && !Number.isNaN(ms) ? Math.max(m, ms) : m;
791
+ const ms = stampMs(parsed.timestamp);
792
+ return tooly && ms ? Math.max(m, ms) : m;
621
793
  }
622
794
  catch {
623
795
  return m;
@@ -652,64 +824,29 @@ export const paneIsBusy = (paneText) => stripAnsi(paneText)
652
824
  .filter(Boolean)
653
825
  .slice(-FOOTER_ROWS)
654
826
  .some((l) => BUSY_MARKERS.some((re) => re.test(l)));
655
- const stallRows = (jsonlPath) => {
656
- const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
657
- return readTailBytes(jsonlPath, TAIL_BYTES)
658
- .split("\n")
659
- .flatMap((line) => {
660
- if (!line.trim())
661
- return [];
662
- try {
663
- const parsed = JSON.parse(line);
664
- const row = normalize(parsed);
665
- const role = row?.message?.role;
666
- if (!row || row.isMeta || row.isSidechain || (role !== "user" && role !== "assistant"))
667
- return [];
668
- const content = row.message?.content;
669
- const blocks = Array.isArray(content) ? content : [];
670
- const ms = typeof parsed.timestamp === "number" ? parsed.timestamp : Date.parse(String(parsed.timestamp ?? ""));
671
- return [{
672
- role, ms: Number.isNaN(ms) ? 0 : ms,
673
- apiError: parsed.isApiErrorMessage === true,
674
- toolResult: blocks.length > 0 && blocks.every((b) => b?.type === "tool_result"),
675
- text: stripMeta(blockText(content)).trim(),
676
- }];
677
- }
678
- catch {
679
- return [];
680
- } // truncated first line of the tail window
681
- });
682
- };
683
- export const transcriptStalled = (jsonlPath, pingSigs, quietMs, now = Date.now()) => {
684
- const rows = stallRows(jsonlPath);
685
- const last = rows.at(-1);
686
- if (!last || !last.ms || now - last.ms < quietMs)
687
- return false;
688
- if (last.toolResult)
689
- return true;
690
- if (!(last.role === "assistant" && last.apiError))
691
- return false;
692
- if (/\/login\b|log(ged)? ?in|auth/i.test(last.text))
693
- return false;
694
- const opener = [...rows].reverse().find((r) => r.role === "user" && !r.toolResult);
695
- return !!opener && !isKeepalivePingText(opener.text, pingSigs);
696
- };
697
827
  /** Machinery, not conversation: the `<system-reminder>` wrapper is the same
698
828
  * marker the mirror's meta-stripper and `tailTurns` already drop, so the hint
699
829
  * reaches the agent's context without leaking into WeCom bubbles or peer
700
- * summaries. Empty string for no mentions — appending it stays a no-op. */
701
- export const renderPeerMentionHint = (mentions) => {
830
+ * summaries. A wizard already introduced within the recent injections (`seen`)
831
+ * only gets its name re-listed — the full row is still in context; the usage
832
+ * rule rides along only when the window has none. `cwd` = the recipient's own
833
+ * workspace: a peer working in the same one doesn't repeat it. */
834
+ export const peerMentionPart = (mentions, cwd = "") => (seen) => {
702
835
  if (mentions.length === 0)
703
- return "";
704
- const lines = mentions.map((m) => `- \`.${m.tag}\` ${m.label} —— 一个活着的 wizard, 住在${m.foreign ? `**另一个**聊天${m.chat ? ` (\`${m.chat}\`)` : ""}` : "你这个聊天"}` +
705
- ` (target \`${m.target}\`${m.cwd ? `, 工作区 ${m.cwd}` : ""}), 地址 "${m.address}"。`);
706
- return renderReminder({ wezard: "mention", names: mentions.map((m) => `.${m.tag}`).join(" ") }, [
707
- "上面这条消息里的 `.name` 点的是**别的 wizard**, 不是字面文本:",
708
- ...lines,
709
- "用户要你把它们拉进来、看看它们在干嘛、或者把话带到时, 去叫它们, 别猜、更别替它们回答:",
710
- "peek_peer(address) 读它最近的对话, send_peer(address, text) 派活或推它一把, wait_peer(address) 等它闲下来,",
711
- "wizard_roster() 看全体 wizard 的名字/职责/家谱。地址就是它的全局名字, 原样用上面给的那个。",
712
- "只是提了一嘴、并没有要你去找它 (\".b 说的那个方案\") 就不必调工具 —— 看用户到底要什么。",
713
- ]);
836
+ return { text: "", keys: [] };
837
+ const fresh = mentions.filter((m) => !seen(`m:.${m.tag}`));
838
+ const known = mentions.filter((m) => seen(`m:.${m.tag}`));
839
+ const rule = !seen("rule:mention");
840
+ const row = (m) => `- .${m.tag}${m.job ? ` job="${clipLine(m.job, 80).replace(/"/g, "'")}"` : ""}${m.foreign ? ` chat=${m.chat || "(other)"}` : ""}${m.cwd && m.cwd !== cwd ? ` cwd=${tildePath(m.cwd)}` : ""}`;
841
+ return {
842
+ text: renderReminder({ wezard: "mention", names: mentions.map((m) => `.${m.tag}`).join(" ") }, [
843
+ ...(rule
844
+ ? ["`.name` tokens above are live wizards, not literal text. To involve / check / relay to one: tell_peer · peek_peer · wait_peer(name) — never answer for it; wizard_roster for all. A passing mention needs no tool."]
845
+ : []),
846
+ ...fresh.map(row),
847
+ ...(known.length ? [`${known.map((m) => `.${m.tag}`).join(" ")}: wizards (described earlier)`] : []),
848
+ ]),
849
+ keys: [...(rule ? ["rule:mention"] : []), ...fresh.map((m) => `m:.${m.tag}`)],
850
+ };
714
851
  };
715
852
  //# sourceMappingURL=peers.js.map