wezard 2.6.4 → 2.7.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 (97) hide show
  1. package/CLAUDE.md +6 -6
  2. package/README.md +1 -15
  3. package/dist/cli/codex-hook.js +5 -1
  4. package/dist/cli/codex-hook.js.map +1 -1
  5. package/dist/cli/sync.js +5 -4
  6. package/dist/cli/sync.js.map +1 -1
  7. package/dist/daemon/approval.js +12 -5
  8. package/dist/daemon/approval.js.map +1 -1
  9. package/dist/daemon/chat-log.js +5 -5
  10. package/dist/daemon/chat-log.js.map +1 -1
  11. package/dist/daemon/chat-name.js +5 -2
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/detail.js +28 -5
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/direction.js +3 -2
  16. package/dist/daemon/direction.js.map +1 -1
  17. package/dist/daemon/dispatch.js +6 -0
  18. package/dist/daemon/dispatch.js.map +1 -0
  19. package/dist/daemon/handoff.js +66 -15
  20. package/dist/daemon/handoff.js.map +1 -1
  21. package/dist/daemon/inbound.js +3 -5
  22. package/dist/daemon/inbound.js.map +1 -1
  23. package/dist/daemon/index.js +1072 -714
  24. package/dist/daemon/index.js.map +1 -1
  25. package/dist/daemon/jobs.js +7 -2
  26. package/dist/daemon/jobs.js.map +1 -1
  27. package/dist/daemon/last-response.js +8 -3
  28. package/dist/daemon/last-response.js.map +1 -1
  29. package/dist/daemon/message-events.js +232 -59
  30. package/dist/daemon/message-events.js.map +1 -1
  31. package/dist/daemon/mirror-bridge.js +556 -770
  32. package/dist/daemon/mirror-bridge.js.map +1 -1
  33. package/dist/daemon/mirror-store.js.map +1 -1
  34. package/dist/daemon/notices.js +9 -2
  35. package/dist/daemon/notices.js.map +1 -1
  36. package/dist/daemon/peers.js +122 -143
  37. package/dist/daemon/peers.js.map +1 -1
  38. package/dist/daemon/pending-items.js +33 -34
  39. package/dist/daemon/pending-items.js.map +1 -1
  40. package/dist/daemon/route.js +5 -2
  41. package/dist/daemon/route.js.map +1 -1
  42. package/dist/daemon/router.js +643 -0
  43. package/dist/daemon/router.js.map +1 -0
  44. package/dist/daemon/session-name.js +1 -1
  45. package/dist/daemon/session-name.js.map +1 -1
  46. package/dist/daemon/spawn-tmux.js +32 -13
  47. package/dist/daemon/spawn-tmux.js.map +1 -1
  48. package/dist/daemon/subagent-tail.js +3 -3
  49. package/dist/daemon/subagent-tail.js.map +1 -1
  50. package/dist/daemon/{cc-session.js → wezard-session.js} +32 -2
  51. package/dist/daemon/wezard-session.js.map +1 -0
  52. package/dist/daemon/wizard.js +5 -5
  53. package/dist/daemon/wizard.js.map +1 -1
  54. package/dist/daemon/workflow-nodes.js +3 -3
  55. package/dist/daemon/workflow-nodes.js.map +1 -1
  56. package/dist/daemon/workflow-plans.js +9 -6
  57. package/dist/daemon/workflow-plans.js.map +1 -1
  58. package/dist/mcp/server.js +38 -47
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/shared/chat-http.js +2 -2
  61. package/dist/shared/chat-http.js.map +1 -1
  62. package/dist/shared/chat-view.js +0 -4
  63. package/dist/shared/chat-view.js.map +1 -1
  64. package/dist/shared/cli-backends.js +11 -1
  65. package/dist/shared/cli-backends.js.map +1 -1
  66. package/dist/shared/config.js +3 -44
  67. package/dist/shared/config.js.map +1 -1
  68. package/dist/shared/detail-render.js +36 -11
  69. package/dist/shared/detail-render.js.map +1 -1
  70. package/dist/shared/detail-store.js.map +1 -1
  71. package/dist/shared/message-footer.js +8 -8
  72. package/dist/shared/message-footer.js.map +1 -1
  73. package/dist/shared/reminder.js +104 -37
  74. package/dist/shared/reminder.js.map +1 -1
  75. package/dist/shared/role-render.js +28 -20
  76. package/dist/shared/role-render.js.map +1 -1
  77. package/dist/shared/role-search.js +3 -3
  78. package/dist/shared/role-search.js.map +1 -1
  79. package/dist/shared/role-view.js +50 -30
  80. package/dist/shared/role-view.js.map +1 -1
  81. package/dist/shared/session-label.js +25 -6
  82. package/dist/shared/session-label.js.map +1 -1
  83. package/dist/shared/turn-state.js +7 -7
  84. package/dist/shared/turn-state.js.map +1 -1
  85. package/dist/shared/world.js +1 -3
  86. package/dist/shared/world.js.map +1 -1
  87. package/hooks/pre-tool-use.sh +5 -25
  88. package/package.json +1 -1
  89. package/web/chat.css +3 -33
  90. package/web/chat.js +11 -62
  91. package/dist/daemon/answer-transfer.js +0 -56
  92. package/dist/daemon/answer-transfer.js.map +0 -1
  93. package/dist/daemon/cc-session.js.map +0 -1
  94. package/dist/daemon/receipts.js +0 -968
  95. package/dist/daemon/receipts.js.map +0 -1
  96. package/dist/shared/keepalive.js +0 -46
  97. package/dist/shared/keepalive.js.map +0 -1
@@ -0,0 +1,643 @@
1
+ // 回执路由表: 一件活 = 一行 `<event, owner, receiver>`, 表状态 = 对 append-only 日志的 fold。
2
+ // (方案见 docs/evolve/receipt-router.md §9, 本文件 = §8 的第 1 步「纯核心」。)
3
+ //
4
+ // 为什么要一张表: 旧机制把「这一笔欠谁的」拆在三个地方 —— 信封里的父 k / reply-to、回执
5
+ // 槽的 `from→to`、冻结栈 —— 一件活在飞时谁也说不清到底谁在等谁。这里只有一条边
6
+ // (`receiver`), 其余全是派生查询: 不落第二份数据, 就没有第二份说错的机会。
7
+ //
8
+ // 为什么是 jsonl 而不是 json: 状态机的每一步都是「记下要干什么」, 记全了才能重放。
9
+ // 撕裂的尾行 (写一半崩了) 丢掉即可; 中间行坏 = 表不可信 = 拒绝启动 —— 丢表 = 丢回执,
10
+ // 这与 json-map-store「坏了当空表」正好相反: 那些是缓存, 这是账。
11
+ //
12
+ // 纯核心: `step` / 派生查询 / op 构造器都是纯函数, IO 只在两端 (boot 的 foldJournal +
13
+ // 文件读写, commit 的 appendFileSync)。daemon 单进程、事件循环串行, 无锁。
14
+ import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { createHash, randomUUID } from "node:crypto";
16
+ import { dirname } from "node:path";
17
+ import { expandHome } from "../shared/paths.js";
18
+ import { clipLine } from "../shared/std.js";
19
+ // 收口标记的语法只有一份 (peers.parseClosing)。router 只认标记不解释正文 (见 §5) ——
20
+ // 但标记长什么样必须与回执 watcher 完全同源, 所以借它的解析而不是自己再写一遍正则。
21
+ import { clipMiddle, parseClosing } from "./peers.js";
22
+ export const EMPTY = {};
23
+ const WAITING = new Set(["held", "open", "need", "error", "answered"]);
24
+ /** `handed` 是墓碑 = 只读的投递路标: 子行沿它往上走 chain / 升级, owner 的追述沿它的 receiver
25
+ * 原路再投 (§4.3); 落定满 TOMBSTONE_TTL_MS 后在压实时回收 (§4.6)。 */
26
+ const isLive = (r) => r.status !== "handed";
27
+ /** 子行 open 时把父行的序号顶到 max(现序号, 本行序号)。子行 id 就是父行 id + "." + 序号。 */
28
+ const parentOfId = (table, event) => {
29
+ const cut = event.lastIndexOf(".");
30
+ return cut <= 0 ? undefined : table[event.slice(0, cut)];
31
+ };
32
+ const bumpParentSeq = (table, child) => {
33
+ const cut = child.lastIndexOf(".");
34
+ if (cut <= 0)
35
+ return table;
36
+ const parent = parentOfId(table, child);
37
+ const n = Number(child.slice(cut + 1));
38
+ if (!parent || !Number.isInteger(n) || n <= parent.seq)
39
+ return table;
40
+ return { ...table, [parent.event]: { ...parent, seq: n } };
41
+ };
42
+ /** 子行的期限不超过父行的剩余期限 (§4.2, gRPC deadline propagation): 父已经超时的活, 子再干也没人等。
43
+ * 开行时夹一次 —— 父由 event id 的前缀认出, 于是每条开行路径都自动带上, 不落第二条 op。 */
44
+ const clampDeadline = (table, event, deadlineAt) => {
45
+ const parent = parentOfId(table, event);
46
+ return parent ? Math.min(deadlineAt, parent.deadlineAt) : deadlineAt;
47
+ };
48
+ /** 唯一的状态机。纯: 同一个表 + 同一条 op → 同一个表; 恒等 (no-op) 的 op 原样返回。 */
49
+ export const step = (table, op) => {
50
+ switch (op.op) {
51
+ case "open": {
52
+ // 重放同一段日志不该分叉: 同一个 event 只认第一次插行。
53
+ if (table[op.event])
54
+ return table;
55
+ const row = {
56
+ event: op.event, owner: op.owner, receiver: op.receiver,
57
+ status: op.status ?? "open", at: op.at, deadlineAt: clampDeadline(table, op.event, op.deadlineAt), seq: 0,
58
+ ...(op.job ? { job: op.job } : {}),
59
+ ...(op.asker ? { asker: op.asker } : {}),
60
+ ...(op.after?.length ? { after: [...op.after] } : {}),
61
+ };
62
+ return bumpParentSeq({ ...table, [op.event]: row }, op.event);
63
+ }
64
+ case "release": {
65
+ const r = table[op.event];
66
+ return r && r.status === "held" ? { ...table, [op.event]: { ...r, status: "open" } } : table;
67
+ }
68
+ case "mark": {
69
+ const r = table[op.event];
70
+ // 定论写一次: need / error 不改写已落定的行 (不变量 2)。
71
+ if (!r || r.status === "answered" || r.status === "handed" || r.status === op.status)
72
+ return table;
73
+ return { ...table, [op.event]: { ...r, status: op.status } };
74
+ }
75
+ case "move": {
76
+ const r = table[op.event];
77
+ return r && r.owner !== op.owner ? { ...table, [op.event]: { ...r, owner: op.owner } } : table;
78
+ }
79
+ case "answer": {
80
+ const r = table[op.event];
81
+ if (!r)
82
+ return table;
83
+ // 落定过的行只有 `late` (owner 的追述) 能再写; 每写一次都推后 settledAt, settledAs 只认第一次。
84
+ if ((r.status === "answered" || r.status === "handed") && !op.late)
85
+ return table;
86
+ return { ...table, [op.event]: {
87
+ ...r, status: "answered", outcome: op.outcome, settledAt: op.at,
88
+ settledAs: r.settledAs ?? r.outcome?.status ?? op.outcome.status,
89
+ ...(op.late ? { late: true } : {}),
90
+ } };
91
+ }
92
+ case "hand": {
93
+ const r = table[op.event];
94
+ // 先 answer 再入箱, 入箱成功才 hand (不变量 1): 只有落定过的行能变墓碑。
95
+ if (!r || r.status !== "answered")
96
+ return table;
97
+ return { ...table, [op.event]: { ...r, status: "handed" } };
98
+ }
99
+ case "drop": {
100
+ const r = table[op.event];
101
+ if (!r || r.receiver.kind !== "wizard" || r.receiver.event === undefined)
102
+ return table;
103
+ return { ...table, [op.event]: { ...r, receiver: { kind: "wizard", to: r.receiver.to } } };
104
+ }
105
+ case "gc": {
106
+ if (!table[op.event])
107
+ return table;
108
+ const { [op.event]: _gone, ...rest } = table;
109
+ return rest;
110
+ }
111
+ case "row": {
112
+ const { op: _tag, ...snapshot } = op;
113
+ return { ...table, [snapshot.event]: snapshot };
114
+ }
115
+ }
116
+ };
117
+ // ── op 构造器 (`at` 缺省取当前时刻: 构造器是这一层唯一碰时钟的地方) ─────────
118
+ /** 定论 / 中途态正文的指纹: 投递幂等键 (`rcpt:<event>:<rev>`) 的一半, 与内容绑定 ——
119
+ * 同 body 重放同 rev, 改了内容就是新 rev (§9.1)。 */
120
+ export const contentRev = (body) => createHash("sha256").update(body).digest("hex").slice(0, 12);
121
+ /** 定论。`rev` 与内容绑定, 见 contentRev。 */
122
+ export const makeOutcome = (status, body) => ({ status, body, rev: contentRev(body) });
123
+ export const ops = {
124
+ /** 插行。带 `after` = held (前置未落定, 先不投给 owner), 否则 open。 */
125
+ open: (p) => ({
126
+ op: "open", at: p.at ?? Date.now(), event: p.event, owner: p.owner, receiver: p.receiver, deadlineAt: p.deadlineAt,
127
+ status: p.after?.length ? "held" : "open",
128
+ ...(p.job ? { job: p.job } : {}),
129
+ ...(p.asker ? { asker: p.asker } : {}),
130
+ ...(p.after?.length ? { after: [...p.after] } : {}),
131
+ }),
132
+ /** held → open: 前置全落定。 */
133
+ release: (p) => ({ op: "release", at: p.at ?? Date.now(), event: p.event }),
134
+ /** 中途态。带 body = 这份正文同时要进 receiver 收件箱 (`rcpt:<event>:<rev>`, §9.3); `rev`
135
+ * 不给就按正文算 (与 outcome 同一个指纹)。 */
136
+ mark: (p) => {
137
+ const rev = p.rev ?? (p.body === undefined ? undefined : contentRev(p.body));
138
+ return {
139
+ op: "mark", at: p.at ?? Date.now(), event: p.event, status: p.status,
140
+ ...(p.body !== undefined ? { body: p.body } : {}),
141
+ ...(rev !== undefined ? { rev } : {}),
142
+ };
143
+ },
144
+ /** transfer: 改 owner (前置 `waiting(event)` 为空, 否则 409 —— 校验在入口层)。 */
145
+ move: (p) => ({ op: "move", at: p.at ?? Date.now(), event: p.event, owner: p.owner }),
146
+ /** 定论落 outbox。`late` = 行已落定之后 owner 的追述 (§4.3)。 */
147
+ answer: (p) => ({
148
+ op: "answer", at: p.at ?? Date.now(), event: p.event, outcome: p.outcome, ...(p.late ? { late: true } : {}),
149
+ }),
150
+ /** 已入 receiver 收件箱 / 已排进群答复; `inbox` = 投递幂等 id。 */
151
+ hand: (p) => ({ op: "hand", at: p.at ?? Date.now(), event: p.event, inbox: p.inbox }),
152
+ /** `pending_items({drop})`: 摘掉 receiver 挂的那一笔 (变 detach), 行不动。 */
153
+ drop: (p) => ({ op: "drop", at: p.at ?? Date.now(), event: p.event }),
154
+ /** 墓碑回收。 */
155
+ gc: (p) => ({ op: "gc", at: p.at ?? Date.now(), event: p.event }),
156
+ };
157
+ // ── 派生查询 (§2.2): 全是现算, 不另存 ────────────────────────────────────
158
+ /** 行的稳定次序: 先按插入时刻, 再按 id —— 派生的列表 (waiting / chain / 名册) 都靠它定序。 */
159
+ const byAt = (rows) => [...rows].sort((a, b) => a.at - b.at || (a.event < b.event ? -1 : a.event > b.event ? 1 : 0));
160
+ export const allRows = (table) => byAt(Object.values(table));
161
+ export const routesOf = (table, owner) => byAt(Object.values(table).filter((r) => r.owner === owner));
162
+ /** receiver 指向 e 的行 (不论落定没有) —— 沿它往上走就是 chain / subtree 用的那条边。 */
163
+ const childrenOf = (table, event) => byAt(Object.values(table).filter((r) => r.receiver.kind === "wizard" && r.receiver.event === event));
164
+ /** receiver 指向 e、还没落定的行 —— 扇出的兄弟 / 挂着的子行 / 停在等 re 的 need。 */
165
+ export const waiting = (table, event) => childrenOf(table, event).filter((r) => WAITING.has(r.status));
166
+ /** 沿 receiver 边往上走到 chat 或 detach (带环保护), 含起点那一行。方向闸 / 升级 / 判环用。 */
167
+ export const chain = (table, event) => {
168
+ const out = [];
169
+ const seen = new Set();
170
+ let cur = event;
171
+ while (cur && !seen.has(cur)) {
172
+ seen.add(cur);
173
+ const row = table[cur];
174
+ if (!row)
175
+ break;
176
+ out.push(row);
177
+ cur = row.receiver.kind === "wizard" ? row.receiver.event : undefined;
178
+ }
179
+ return out;
180
+ };
181
+ /** receiver 边的传递闭包, 叶子先出 (级联取消按这个顺序落)。不含 e 自己。
182
+ * 跨越 handed 的中间行: 父行先收口后, 它的子行是孤儿 (见 §4.3) —— 少走这一步就丢了一整支,
183
+ * 而墓碑本身对取消是 no-op (定论写一次)。 */
184
+ export const subtree = (table, event) => {
185
+ const out = [];
186
+ const walk = (id, seen) => childrenOf(table, id).forEach((child) => {
187
+ if (seen.has(child.event))
188
+ return;
189
+ seen.add(child.event);
190
+ walk(child.event, seen);
191
+ out.push(child);
192
+ });
193
+ walk(event, new Set([event]));
194
+ return out;
195
+ };
196
+ /** 这一笔的定论是给人 / 群看的 —— topOnly 收口、eventFinalGate 三套推断都退化成这一条。 */
197
+ export const speaksToHuman = (table, event) => table[event]?.receiver.kind === "chat";
198
+ /** W 欠着的行 (含 held 的), 未 handed 的才算欠。 */
199
+ export const owedBy = (table, owner) => byAt(Object.values(table).filter((r) => r.owner === owner && isLive(r)));
200
+ /** 派给 W、还没了结的行 —— pending_items 的「我派出去还没了结的」。 */
201
+ export const awaitedBy = (table, owner) => byAt(Object.values(table).filter((r) => r.receiver.kind === "wizard" && r.receiver.to === owner && isLive(r)));
202
+ /** 派给 W 的件里**已经有回执要交还给它的**那些 (含已 `handed` 的墓碑) —— `handed` 只说那份已进
203
+ * 收件箱, 而收件箱里可能还排着队没投; `wait_peer` 按 event 取 outcome 从这里 (§9.5)。
204
+ * `awaitedBy` 是它的「还没了结」版: 一份定论一交付就出了 `awaitedBy`, 恰恰是这里要取的那份。 */
205
+ export const settledTo = (table, owner) => byAt(Object.values(table).filter((r) => r.receiver.kind === "wizard" && r.receiver.to === owner
206
+ && (r.outcome !== undefined || r.status === "need" || r.status === "error")));
207
+ // ── 工具参数的引用解析: 模型只说「对谁」, 那一笔由表推断 ──────────────────
208
+ /** 模型抄来的引用 → event id: 剥掉信封上 message-event 的 `route:` / `rcpt:` 前缀与
209
+ * `:rN` / `:resume` / `:<rev>` 后缀 (最常见的抄错就是抄了那一个)。 */
210
+ export const eventRef = (raw) => String(raw ?? "").replace(/[`\s]/g, "").replace(/^(?:route|rcpt|canceled|chat):/, "").replace(/:.*$/, "");
211
+ /** 是 event id (`e<hex>[.n…]`, T10 前的老件号 `t<hex>`) 而不是名字。 */
212
+ export const isEventRef = (s) => /^[et][0-9a-f]{6,}(?:\.\d+)*$/.test(s);
213
+ const PAIR_LIVE = new Set(["held", "open", "need", "error"]);
214
+ /** `from` 派给 `to` (此刻的 owner, transfer 过就是接手方) 的「那一笔」: `hint` (本轮在读的那份回执)
215
+ * 命中 → 它; 在飞的唯一 → 它; 多于一笔 → 歧义, 交给调用方列出来让模型指明; 没有在飞的 →
216
+ * 最近一笔 (可能已落定, 也可能从没派过 = undefined)。 */
217
+ export const pickPair = (table, from, to, hint) => {
218
+ // hint 不看 receiver: 沿链升级投来的回执, 行上的 receiver 还是那个已不在的 wizard。
219
+ const hit = hint !== undefined && table[hint]?.owner === to ? table[hint] : undefined;
220
+ if (hit)
221
+ return { ok: true, row: hit };
222
+ const rows = byAt(Object.values(table).filter((r) => r.receiver.kind === "wizard" && r.receiver.to === from && r.owner === to)).reverse();
223
+ const live = rows.filter((r) => PAIR_LIVE.has(r.status));
224
+ return live.length > 1 ? { ok: false, many: live } : { ok: true, ...(live[0] ?? rows[0] ? { row: live[0] ?? rows[0] } : {}) };
225
+ };
226
+ /** 升级 (§4.4): 收方那个 wizard 已经不在注册表里 (被 stop / 注销) → 沿 receiver 边往上找第一个
227
+ * 还活着的 receiver, 定论改投给它, 信封标 `via .W (已不在)`; 一路走到 chat 根 = Dead Letter
228
+ * (进那个群, 由那个群的顶层 wizard 收)。
229
+ *
230
+ * - 「还活着」只认注册表: 注销 (forget) 才抹记录 —— 只是 pane 离线、或还没生出来的, 仍是它,
231
+ * 定论留在它的持久化收件箱里等 (现状: 不为送回执重开会话)。
232
+ * - 认不出更上一层 (父行已 gc) 或链上成环 → 无处可升, 照旧进原收方的收件箱 (结论不丢)。
233
+ * 原样能投 = undefined (调用方照旧走行上的 receiver)。 */
234
+ export const escalate = (table, event, alive) => {
235
+ const start = table[event];
236
+ if (!start || start.receiver.kind !== "wizard" || alive(start.receiver.to))
237
+ return undefined;
238
+ const via = [];
239
+ const seen = new Set([event]);
240
+ let r = start.receiver;
241
+ while (r.kind === "wizard" && !alive(r.to)) {
242
+ via.push(r.to);
243
+ const up = r.event !== undefined ? table[r.event] : undefined;
244
+ if (!up || seen.has(up.event))
245
+ return undefined;
246
+ seen.add(up.event);
247
+ r = up.receiver;
248
+ }
249
+ return r.kind === "wizard" ? { receiver: r, serves: r.event ?? "", via } : { receiver: r, serves: "", via };
250
+ };
251
+ /** 孤儿 (§4.3): 这一笔的结论要服务的那一笔已经收口 (`answered` / `handed`), 或已不在表上 ——
252
+ * 结论照投 (信封标迟到); 那一轮的终句要再往上冒, 得作为 f 的追述被 onFinal 收下 (见 retellOps),
253
+ * f 已回收 (gc) 就只进 rolepage。 */
254
+ export const isOrphaned = (table, serves) => {
255
+ if (!serves)
256
+ return false;
257
+ const row = table[serves];
258
+ return !row || row.status === "answered" || row.status === "handed";
259
+ };
260
+ /** 终句的语义: 显式标记优先 (NEED 是反问、RESULT 是定论), 未标记只看 `waiting(e)` ——
261
+ * 还挂着子行 = 进展 (只进 rolepage, 行不动), 没有 = 定论 (§3.2)。 */
262
+ export const finalKind = (table, event, text) => {
263
+ const kind = parseClosing(text).kind;
264
+ if (kind === "need")
265
+ return "need";
266
+ if (kind === "result")
267
+ return "result";
268
+ return event !== undefined && waiting(table, event).length > 0 ? "progress" : "done";
269
+ };
270
+ /** owner 一轮结束时该落的那几条 op (§3.2 的表)。
271
+ *
272
+ * - `serves` 缺省 / 没有这一行 = 这一轮不服务任何一笔 (人在 CLI 里敲的、detach 回执) → 无 op。
273
+ * 没有行就没有 receiver, 也就无处投递 —— 所以这里不是「漏了」, 是没得投 (§9.4)。
274
+ * - 行已经 transfer 给别人 = 旧 owner 的终句不再算数 (§3.4) → 无 op。
275
+ * - held (还没投给 owner) 的行不收终句 → 无 op; 已落定的行只收 owner 的追述 (retellOps)。
276
+ * - NEED / error 是中途态, 行留着; 重复报同一状态也不再产 op (行状态就是那个去重记号)。 */
277
+ export const onFinal = (table, owner, serves, verdict) => {
278
+ const row = serves !== undefined && serves !== "" ? table[serves] : undefined;
279
+ if (!row || row.owner !== owner)
280
+ return [];
281
+ if (row.status === "answered" || row.status === "handed")
282
+ return retellOps(table, row, verdict);
283
+ if (row.status === "held")
284
+ return [];
285
+ if (verdict.error)
286
+ return row.status === "error" ? [] : [ops.mark({ event: row.event, status: "error", body: verdict.text })];
287
+ const kind = finalKind(table, row.event, verdict.text);
288
+ if (kind === "need")
289
+ return row.status === "need" ? [] : [ops.mark({ event: row.event, status: "need", body: verdict.text })];
290
+ if (kind === "progress")
291
+ return [];
292
+ return [ops.answer({ event: row.event, outcome: makeOutcome("done", verdict.text) })];
293
+ };
294
+ /** daemon 合成的失败: 不是 owner 的定论, 所以它之后未标记的真答也算数。 */
295
+ const SYNTH = new Set(["timeout", "silent", "dead"]);
296
+ /** 已落定的行再收 owner 一轮终句 = 追述 (§4.3): 行的 receiver 边一直在, 追述沿它原路再投一次
297
+ * (新内容 = 新 rev, 同内容重放 = 无 op)。收不收由 owner 的标记定 (daemon 定路由, LLM 定语义):
298
+ * - 起初是合成失败 (timeout / silent / dead): 未标记的定论也收, 可收多次;
299
+ * - 起初是 done: 只收显式 `RESULT:` —— 未标记的是收到回执后的随口一句, 只进 rolepage;
300
+ * - `canceled` 一律不收: 那一笔是被人 / 随工单收掉的, 子树整支 TERMINATE (§4.2)。 */
301
+ const retellOps = (table, row, verdict) => {
302
+ const o = row.outcome;
303
+ if (verdict.error || !o || o.status === "canceled" || contentRev(verdict.text) === o.rev)
304
+ return [];
305
+ const kind = finalKind(table, row.event, verdict.text);
306
+ const take = kind === "result" || (kind === "done" && SYNTH.has(row.settledAs ?? o.status));
307
+ return take ? [ops.answer({ event: row.event, outcome: makeOutcome("done", verdict.text), late: true })] : [];
308
+ };
309
+ /** 这一轮的终句该不该进群 (eventFinalGate, §9.5): 没有行 = 老事件照放; held = 还没轮到它;
310
+ * 在飞 = 不再挂着未落定的子行; 已落定 = 只放追述 —— 表上的 outcome 就是这句 (onFinal 已收下),
311
+ * 或这句带 `RESULT:`、owner 还是它、那一笔没被收掉 (onFinal 会收下, 与它谁先跑无关)。 */
312
+ export const finalReleased = (table, owner, serves, body) => {
313
+ const row = serves ? table[serves] : undefined;
314
+ if (!row)
315
+ return true;
316
+ if (row.status === "held")
317
+ return false;
318
+ if (row.status !== "answered" && row.status !== "handed")
319
+ return waiting(table, row.event).length === 0;
320
+ const o = row.outcome;
321
+ return row.owner === owner && !!o && o.status !== "canceled"
322
+ && (o.rev === contentRev(body) || parseClosing(body).kind === "result");
323
+ };
324
+ // ── 失败合成 (§4.2): 和 done 走同一条 outbox ──────────────────────────────
325
+ // 失败不是特例: timeout / silent / dead / canceled 都是一份定论 (`answer`), 走同一个
326
+ // receiver、同一条投递通路。合成完全在 daemon 这边做 —— 模型不报失败, 它只会没声。
327
+ /** 期限常量 (§9.6: 自 receipts 迁入): 对方最长允许干多久 / 期限的上下限。上限留在工单
328
+ * 保留期内, 超时那份回执还得数得进账。 */
329
+ export const TARGET_WAIT_SEC = 3600;
330
+ export const DEADLINE_MIN_SEC = 60;
331
+ export const DEADLINE_MAX_SEC = 12 * 3600;
332
+ /** `at` 发出、给了 `sec` 秒 (缺省 / 越界取默认或夹到边上) 的期限。 */
333
+ export const deadlineOf = (at, sec) => at + 1000 * (sec === undefined || !Number.isFinite(sec) ? TARGET_WAIT_SEC : Math.min(Math.max(sec, DEADLINE_MIN_SEC), DEADLINE_MAX_SEC));
334
+ /** pane 不在了, 隔这么久再看一眼才判 dead: 非交接的 pane 换新 (respawn) 也有几秒是死的。 */
335
+ export const DEAD_GRACE_MS = 5000;
336
+ /** 对方读进了我们这一句、停下了却没答 —— 连着这么多次就认定它不会答了 (§9.4)。 */
337
+ export const MAX_FRUITLESS = 3;
338
+ /** 失败回执的正文 (与 receipts 的 watcher 同一套文案): 守护进程的一句说明 + 对方最后一句
339
+ * (给发话方一点线索)。 */
340
+ export const failureText = (p) => `(守护进程: ${p.who} 没有给出结论 —— ${p.why}。)${p.last ? `\n它最后说的: ${clipMiddle(p.last, 600)}` : ""}`;
341
+ /** 三种合成失败的「理由」半句 (主语由 failureText 写)。 */
342
+ export const failureWhy = (p) => p.status === "timeout"
343
+ ? (p.need ? `它的反问 (NEED) 等了 ${p.minutes} 分钟没人答` : `等了 ${p.minutes} 分钟它还没答完`)
344
+ : p.status === "silent" ? `它停下了 ${MAX_FRUITLESS} 次, 都没答这一句` : "它的 pane 没了";
345
+ /** 被收掉 / 打断 / 前置失败 的正文 (§4.2 canceled)。`reason` 是一整句 (含句号), 因为它
346
+ * 有三种来路: 被谁收掉、随工单收了、等的前置以某结局收场。 */
347
+ export const canceledText = (p) => `(守护进程: ${p.who ? `${p.who} ` : ""}${p.reason})${p.last ? `\n它最后说的: ${clipMiddle(p.last, 600)}` : ""}`;
348
+ /** 扫一遍在飞的行, 合成失败定论 (§4.2)。判定顺序即优先级: held 前置失败 → timeout → dead → silent
349
+ * —— 期限到了就是期限到了, 死没死都得报 timeout (旧 watcher 同序)。纯函数: 只产 op,
350
+ * 落盘与投递在副作用层。 */
351
+ export const sweepOps = (table, p) => {
352
+ const who = (w) => p.nameOf?.(w) ?? w;
353
+ const last = (w, since) => p.lastWords?.(w, since)?.trim() || undefined;
354
+ const failed = (r, body, status) => ops.answer({ at: p.now, event: r.event, outcome: makeOutcome(status, body) });
355
+ return allRows(table).flatMap((r) => {
356
+ if (r.status === "held") {
357
+ // 前置以非 done 收场: 这件没投, 也不投 —— 但发话方得知道, 附上前置的结局快照 (§4.2)。
358
+ const bad = (r.after ?? []).map((a) => [a, table[a]]).find(([, x]) => !!x?.outcome && x.outcome.status !== "done");
359
+ if (!bad)
360
+ return [];
361
+ const [a, x] = bad;
362
+ const snap = `${a} (${who(x.owner)}) 以 ${x.outcome.status} 收场`;
363
+ return [failed(r, canceledText({ who: who(r.owner), reason: `等的前置 ${snap}, 这件 (${r.event}) 没投。`, last: last(r.owner, r.at) }), "canceled")];
364
+ }
365
+ if (r.status !== "open" && r.status !== "need" && r.status !== "error")
366
+ return [];
367
+ if (r.deadlineAt <= p.now) {
368
+ const why = failureWhy({ status: "timeout", minutes: Math.round((p.now - r.at) / 60_000), need: r.status === "need" });
369
+ return [failed(r, failureText({ who: who(r.owner), why, last: last(r.owner, r.at) }), "timeout")];
370
+ }
371
+ if ((p.paneDeadForMs(r.owner) ?? 0) >= DEAD_GRACE_MS && !(p.handingOff?.(r.owner) ?? false))
372
+ return [failed(r, failureText({ who: who(r.owner), why: failureWhy({ status: "dead" }), last: last(r.owner, r.at) }), "dead")];
373
+ if (p.fruitless(r.event) >= MAX_FRUITLESS)
374
+ return [failed(r, failureText({ who: who(r.owner), why: failureWhy({ status: "silent" }), last: last(r.owner, r.at) }), "silent")];
375
+ return [];
376
+ });
377
+ };
378
+ /** 级联取消 (§4.2 TERMINATE): `subtree(e)` 全部 canceled —— **叶子先落**, 根最后 (父若先落,
379
+ * 子行的失败就没处冒了)。返回要落的 op (按顺序): 谁该收到回执由副作用层判 —— 发起者
380
+ * 就是 receiver 的那份不投 (它自己知道), 但行照样得落成墓碑, 不然一直挂在 owedBy 上。 */
381
+ export const cancelOps = (table, roots, p) => {
382
+ const at = p.at ?? Date.now();
383
+ const seen = new Set();
384
+ const order = [];
385
+ roots.forEach((root) => {
386
+ [...subtree(table, root), table[root]].forEach((r) => {
387
+ if (r && !seen.has(r.event)) {
388
+ seen.add(r.event);
389
+ order.push(r);
390
+ }
391
+ });
392
+ });
393
+ return order.flatMap((r) => {
394
+ // 定论写一次 (不变量 2): 已 answered / handed 的行不再改写。
395
+ if (r.status === "answered" || r.status === "handed")
396
+ return [];
397
+ const last = p.lastWords?.(r.owner, r.at)?.trim() || undefined;
398
+ const body = canceledText({ who: (p.nameOf?.(r.owner) ?? r.owner), reason: p.reason(r), last });
399
+ return [ops.answer({ at, event: r.event, outcome: makeOutcome("canceled", body) })];
400
+ });
401
+ };
402
+ // ── 墓碑回收 (§4.6): compact 时的纯过滤 ──────────────────────────────────
403
+ /** 墓碑落定后保留多久: 这段时间里它仍是追述 / 升级 / 孤儿判定的路标。与工单已关行的保留期对齐。 */
404
+ export const TOMBSTONE_TTL_MS = 24 * 3600_000;
405
+ /** 能回收的行: 自己是满 TTL 的墓碑、没有 held 行的 `after` 等着它, 且整棵子树都能回收 ——
406
+ * 子树闭合 (收一行必收它全部后代), 于是留下的行往上走 chain 永远走得通。没有 settledAt 的
407
+ * 老行按 deadlineAt 起算 (合成 timeout 的时刻, 正常定论只会更早)。 */
408
+ export const collectable = (table, now) => {
409
+ const rows = Object.values(table);
410
+ const awaited = new Set(rows.flatMap((r) => (r.status === "held" ? r.after ?? [] : [])));
411
+ const kids = rows.reduce((m, r) => {
412
+ const up = r.receiver.kind === "wizard" ? r.receiver.event : undefined;
413
+ return up === undefined ? m : m.set(up, [...(m.get(up) ?? []), r]);
414
+ }, new Map());
415
+ const memo = new Map();
416
+ const ok = (r) => {
417
+ const hit = memo.get(r.event);
418
+ if (hit !== undefined)
419
+ return hit;
420
+ memo.set(r.event, false); // 环保护: 回到自己 = 不收
421
+ const v = r.status === "handed" && now - (r.settledAt ?? r.deadlineAt) >= TOMBSTONE_TTL_MS
422
+ && !awaited.has(r.event) && (kids.get(r.event) ?? []).every(ok);
423
+ memo.set(r.event, v);
424
+ return v;
425
+ };
426
+ return new Set(rows.filter(ok).map((r) => r.event));
427
+ };
428
+ /** 表减去能回收的墓碑。 */
429
+ export const collect = (table, now) => {
430
+ const gone = collectable(table, now);
431
+ return gone.size ? Object.fromEntries(Object.entries(table).filter(([e]) => !gone.has(e))) : table;
432
+ };
433
+ // ── 日志 (§2.3): 一行一条 op, 中间行坏 = 拒绝启动 ────────────────────────
434
+ /** `~/.wezard/routes.jsonl`。与 wizards.json 同级 (stateDir 在 state/ 下, 那是运行态)。 */
435
+ export const ROUTES_FILE = "~/.wezard/routes.jsonl";
436
+ /** 日志超过这么多行就压一次 —— 重放的成本压在 boot 上, 别让它随 daemon 的寿命长。 */
437
+ const COMPACT_AT = 512;
438
+ const OP_FIELDS = {
439
+ open: ["event", "owner", "receiver", "at", "deadlineAt"],
440
+ release: ["event", "at"],
441
+ mark: ["event", "status", "at"],
442
+ move: ["event", "owner", "at"],
443
+ answer: ["event", "outcome", "at"],
444
+ hand: ["event", "inbox", "at"],
445
+ drop: ["event", "at"],
446
+ gc: ["event", "at"],
447
+ row: ["event", "owner", "receiver", "status", "at", "deadlineAt", "seq"],
448
+ };
449
+ const STATUSES = new Set(["held", "open", "need", "error", "answered", "handed"]);
450
+ const OPEN_STATUSES = new Set(["open", "held"]);
451
+ /** mark 只落中途态 (`handed` 是墓碑, 不给 mark)。 */
452
+ const MARK_STATUSES = new Set(["open", "need", "error"]);
453
+ const OUTCOME_STATUSES = new Set(["done", "timeout", "silent", "dead", "canceled"]);
454
+ const nonEmpty = (v) => typeof v === "string" && v !== "";
455
+ const finite = (v) => typeof v === "number" && Number.isFinite(v);
456
+ const receiverOk = (v) => {
457
+ const r = v;
458
+ if (!r)
459
+ return false;
460
+ return r.kind === "chat"
461
+ ? typeof r.channel === "string" && nonEmpty(r.message)
462
+ : r.kind === "wizard" && nonEmpty(r.to) && (r.event === undefined || typeof r.event === "string");
463
+ };
464
+ const outcomeOk = (v) => {
465
+ const o = v;
466
+ return !!o && OUTCOME_STATUSES.has(o.status) && typeof o.body === "string" && typeof o.rev === "string";
467
+ };
468
+ /** 一行日志 → 一条 op。认不出 = undefined —— 调用方据此判撕裂 (尾行) / 判坏行 (中间行)。
469
+ * 形态要核到字段: 只认得出 `op` 还不够, 半行坏字段 fold 进表就是一行的错。 */
470
+ export const parseOp = (raw) => {
471
+ let obj;
472
+ try {
473
+ obj = JSON.parse(raw);
474
+ }
475
+ catch {
476
+ return undefined;
477
+ }
478
+ if (!obj || typeof obj !== "object" || Array.isArray(obj))
479
+ return undefined;
480
+ const o = obj;
481
+ const op = o.op;
482
+ if (!Object.prototype.hasOwnProperty.call(OP_FIELDS, op))
483
+ return undefined;
484
+ if (!OP_FIELDS[op].every((f) => o[f] !== undefined))
485
+ return undefined;
486
+ if (!nonEmpty(o.event) || !finite(o.at))
487
+ return undefined;
488
+ if ("owner" in o && !nonEmpty(o.owner))
489
+ return undefined;
490
+ if ("receiver" in o && !receiverOk(o.receiver))
491
+ return undefined;
492
+ // status 按 op 分别核: mark 只落中途态, open 只开 open/held, row 快照必须给全。
493
+ if (op === "mark" && !MARK_STATUSES.has(String(o.status)))
494
+ return undefined;
495
+ if (op === "open" && o.status !== undefined && !OPEN_STATUSES.has(String(o.status)))
496
+ return undefined;
497
+ if (op === "row" && !STATUSES.has(String(o.status)))
498
+ return undefined;
499
+ if (op === "row" && !finite(o.seq))
500
+ return undefined;
501
+ if (op === "row" && o.settledAt !== undefined && !finite(o.settledAt))
502
+ return undefined;
503
+ if (op === "row" && o.settledAs !== undefined && !OUTCOME_STATUSES.has(String(o.settledAs)))
504
+ return undefined;
505
+ if (op === "answer" && !outcomeOk(o.outcome))
506
+ return undefined;
507
+ if (op === "hand" && !nonEmpty(o.inbox))
508
+ return undefined;
509
+ return o;
510
+ };
511
+ /** 逐行 parse 后 fold。只有最后一行容许撕裂 (崩溃中途写的半行), 丢掉它;
512
+ * 中间行坏 = 表不可信 = 抛错拒绝启动。 */
513
+ export const foldJournal = (raw) => {
514
+ const lines = raw.split("\n").filter((l) => l.trim() !== "");
515
+ const parsed = lines.map(parseOp);
516
+ const lastAt = parsed.length - 1;
517
+ const bad = parsed.findIndex((op, i) => !op && i !== lastAt);
518
+ if (bad >= 0) {
519
+ throw new Error(`routes.jsonl 第 ${bad + 1} 行不是一条合法 op —— 丢表 = 丢回执, 拒绝启动: ${clipLine(lines[bad] ?? "", 120)}`);
520
+ }
521
+ const commits = parsed.filter((op) => !!op);
522
+ return { table: commits.reduce(step, EMPTY), commits: commits.length, dropped: parsed.length - commits.length };
523
+ };
524
+ /** tmp + rename 原子替换 (与 json-map-store 的严格写同形)。 */
525
+ const writeAtomic = (file, text) => {
526
+ const tmp = `${file}.${randomUUID()}.tmp`;
527
+ writeFileSync(tmp, text, { mode: 0o600 });
528
+ const fd = openSync(tmp, "r");
529
+ try {
530
+ fsyncSync(fd);
531
+ }
532
+ finally {
533
+ closeSync(fd);
534
+ }
535
+ renameSync(tmp, file);
536
+ const dir = openSync(dirname(file), "r");
537
+ try {
538
+ fsyncSync(dir);
539
+ }
540
+ finally {
541
+ closeSync(dir);
542
+ }
543
+ };
544
+ const fmtReceiver = (r) => r.kind === "chat" ? `chat ${r.channel || "-"}#${r.message}` : `${r.to}${r.event ? `:${r.event}` : " (detach)"}`;
545
+ /** 诊断一行 (GET /routes): 一笔一行, 给人看。 */
546
+ export const renderRoute = (r) => [
547
+ r.event, r.status, `owner=${r.owner}`, `→ ${fmtReceiver(r.receiver)}`,
548
+ `at=${new Date(r.at).toISOString()}`, `dl=${new Date(r.deadlineAt).toISOString()}`,
549
+ r.seq ? `seq=${r.seq}` : "", r.job ? `job=${r.job}` : "",
550
+ r.after?.length ? `after=${r.after.join(",")}` : "",
551
+ r.outcome ? `${r.outcome.status}: ${clipLine(r.outcome.body, 80)}` : "",
552
+ r.late ? "late" : "",
553
+ ].filter(Boolean).join(" ");
554
+ /** boot: 建表 (fold 一次), 之后每次 commit 先落盘。`note` = 日志回调 (省得 router 依赖 pino)。 */
555
+ export const openRouter = (opts = {}) => {
556
+ const file = expandHome(opts.file ?? ROUTES_FILE);
557
+ const note = opts.note ?? (() => { });
558
+ let table = EMPTY;
559
+ let opLines = 0;
560
+ let sinceCompact = 0;
561
+ /** owner 的 pane 第一眼看到死掉的时刻 (§4.2 的 5s 宽限: 换新 / respawn 也有几秒是死的)。 */
562
+ const deadSince = new Map();
563
+ // 墓碑回收挂在压实上: 本来就要整份重写, 少写几行不多一次 IO。
564
+ const compact = () => {
565
+ const kept = collect(table, Date.now());
566
+ const live = allRows(kept);
567
+ writeAtomic(file, live.map((r) => `${JSON.stringify({ op: "row", ...r })}\n`).join(""));
568
+ const gced = allRows(table).length - live.length;
569
+ table = kept;
570
+ opLines = live.length;
571
+ sinceCompact = 0;
572
+ note("routes journal compacted", { file, rows: live.length, gced });
573
+ };
574
+ if (existsSync(file)) {
575
+ const { table: folded, commits, dropped } = foldJournal(readFileSync(file, "utf8"));
576
+ table = folded;
577
+ opLines = commits;
578
+ note("routes journal folded", { file, commits, dropped, rows: allRows(table).length });
579
+ // 撕裂的尾行 / 长日志: 就地压实一次, 让盘上不再留着已经丢掉的半行。
580
+ if (dropped > 0 || commits > COMPACT_AT)
581
+ compact();
582
+ }
583
+ else {
584
+ mkdirSync(dirname(file), { recursive: true });
585
+ appendFileSync(file, "");
586
+ note("routes journal created", { file });
587
+ }
588
+ const commit = (op) => {
589
+ appendFileSync(file, `${JSON.stringify(op)}\n`);
590
+ table = step(table, op);
591
+ opLines += 1;
592
+ if (++sinceCompact > COMPACT_AT)
593
+ compact();
594
+ return table;
595
+ };
596
+ return {
597
+ file,
598
+ table: () => table,
599
+ rows: () => allRows(table),
600
+ get: (event) => table[event],
601
+ waiting: (event) => waiting(table, event),
602
+ chain: (event) => chain(table, event),
603
+ subtree: (event) => subtree(table, event),
604
+ speaksToHuman: (event) => speaksToHuman(table, event),
605
+ owedBy: (owner) => owedBy(table, owner),
606
+ awaitedBy: (owner) => awaitedBy(table, owner),
607
+ settledTo: (owner) => settledTo(table, owner),
608
+ escalate: (event, alive) => escalate(table, event, alive),
609
+ isOrphaned: (serves) => isOrphaned(table, serves),
610
+ finalReleased: (owner, serves, body) => finalReleased(table, owner, serves, body),
611
+ finalKind: (serves, text) => finalKind(table, serves, text),
612
+ onFinal: (owner, serves, verdict) => onFinal(table, owner, serves, verdict),
613
+ sweep: async (deps) => {
614
+ const now = deps.now ?? Date.now();
615
+ const live = allRows(table).filter((r) => r.status === "open" || r.status === "need" || r.status === "error");
616
+ const owners = [...new Set(live.map((r) => r.owner))];
617
+ // 一个 owner 只探一次: paneLive 要 spawn tmux, 十几路 fan-out 别按行探。
618
+ for (const w of owners) {
619
+ if (await deps.paneLive(w))
620
+ deadSince.delete(w);
621
+ else if (!deadSince.has(w))
622
+ deadSince.set(w, now);
623
+ }
624
+ // 已经不在飞了, 别把它的死记账留着 (下次出生会白捡一个宽限)。
625
+ for (const w of [...deadSince.keys()])
626
+ if (!owners.includes(w))
627
+ deadSince.delete(w);
628
+ const since = (w) => { const t = deadSince.get(w); return t === undefined ? undefined : now - t; };
629
+ return sweepOps(table, {
630
+ now, paneDeadForMs: since, fruitless: deps.fruitless,
631
+ ...(deps.handingOff ? { handingOff: deps.handingOff } : {}),
632
+ ...(deps.nameOf ? { nameOf: deps.nameOf } : {}), ...(deps.lastWords ? { lastWords: deps.lastWords } : {}),
633
+ });
634
+ },
635
+ commit,
636
+ compact,
637
+ diagnose: (owner) => {
638
+ const rows = owner ? routesOf(table, owner) : allRows(table);
639
+ return { file, opLines, count: rows.length, rows, text: rows.map(renderRoute).join("\n") };
640
+ },
641
+ };
642
+ };
643
+ //# sourceMappingURL=router.js.map