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.
- package/CLAUDE.md +2 -2
- package/dist/daemon/chat-log.js +107 -0
- package/dist/daemon/chat-log.js.map +1 -0
- package/dist/daemon/chat-name.js +1 -1
- package/dist/daemon/detail.js +7 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/inbound.js +10 -3
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +125 -22
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +81 -44
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/notices.js +3 -5
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +260 -125
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/session-name.js +1 -1
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/wizard.js +33 -2
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +34 -12
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +25 -10
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +17 -7
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/config.js +6 -6
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +22 -5
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/reminder.js +56 -0
- package/dist/shared/reminder.js.map +1 -0
- package/dist/shared/role-render.js +6 -3
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +3 -3
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +9 -3
- package/dist/shared/session-label.js.map +1 -1
- package/hooks/pre-tool-use.sh +1 -1
- package/package.json +4 -1
- package/web/chat.css +15 -11
- package/web/chat.js +38 -5
- package/web/highlight.min.js +1262 -0
- package/web/hljs-github.min.css +10 -0
- package/web/markdown-it.min.js +2 -0
package/dist/daemon/notices.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// wizard_roster 问到; 为一行提示做可靠投递不值当。信箱是纯内存的, daemon 重启即
|
|
17
17
|
// 清空 —— 同 graph run, 诚实地说: pane 还在, 没送到的提示没了。
|
|
18
18
|
import { baseOfKey } from "../shared/session-label.js";
|
|
19
|
+
import { renderReminder } from "../shared/reminder.js";
|
|
19
20
|
/** 每个信箱最多攒 `max` 行 —— 一个挂了很久的 wizard 不该在醒来时读一部编年史,
|
|
20
21
|
* 溢出时留最新的那些 (旧的那些多半已经被后面的变动覆盖了)。 */
|
|
21
22
|
export const createNoticeBox = (max = 12) => {
|
|
@@ -45,15 +46,12 @@ export const noticeBox = () => bound;
|
|
|
45
46
|
export const renderNotices = (lines) => {
|
|
46
47
|
if (lines.length === 0)
|
|
47
48
|
return "";
|
|
48
|
-
return [
|
|
49
|
-
"",
|
|
50
|
-
"<system-reminder>",
|
|
49
|
+
return renderReminder({ wezard: "roster" }, [
|
|
51
50
|
"你出生时拿到的那份名册已经变了 —— 这段时间里:",
|
|
52
51
|
...lines.map((l) => `- ${l}`),
|
|
53
52
|
"要当下真实的状态 (谁在忙、谁在哪个工作区) 就调 wizard_roster。与手头的活无关就略过,",
|
|
54
53
|
"**不要为此回话, 也不要向用户复述这几行** —— 群里该看见的气泡已经发过了。",
|
|
55
|
-
|
|
56
|
-
].join("\n");
|
|
54
|
+
]);
|
|
57
55
|
};
|
|
58
56
|
/** 注入边界上取一次增量。slash 命令按行解析, 尾巴上多挂一段会让它不再被识别成
|
|
59
57
|
* 命令 —— 同 inbound 对 mention hint 的处理, 这类注入直接跳过 (信箱不清空, 等
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"notices.js","sourceRoot":"","sources":["../../daemon/notices.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,EAAE;AACF,wDAAwD;AACxD,+CAA+C;AAC/C,qCAAqC;AACrC,EAAE;AACF,8CAA8C;AAC9C,4CAA4C;AAC5C,oDAAoD;AACpD,kEAAkE;AAClE,UAAU;AACV,EAAE;AACF,4CAA4C;AAC5C,EAAE;AACF,uCAAuC;AACvC,sDAAsD;AACtD,8CAA8C;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AASvD;qCACqC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,GAAG,GAAG,EAAE,EAAa,EAAE;IACrD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC1C,OAAO;QACL,IAAI,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE;YACvB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;gBAAE,OAAO;YACzB,KAAK,MAAM,CAAC,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAClC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC5D,CAAC;QACH,CAAC;QACD,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE;YAChB,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YACtC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YACrB,OAAO,KAAK,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,wDAAwD;AACxD,4CAA4C;AAC5C,8CAA8C;AAC9C,IAAI,KAA4B,CAAC;AACjC,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,GAAc,EAAa,EAAE,CAAC,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC;AAC1E,MAAM,CAAC,MAAM,SAAS,GAAG,GAA0B,EAAE,CAAC,KAAK,CAAC;AAE5D,8DAA8D;AAC9D,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,KAAwB,EAAU,EAAE;IAChE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,OAAO
|
|
1
|
+
{"version":3,"file":"notices.js","sourceRoot":"","sources":["../../daemon/notices.ts"],"names":[],"mappings":"AAAA,6BAA6B;AAC7B,EAAE;AACF,wDAAwD;AACxD,+CAA+C;AAC/C,qCAAqC;AACrC,EAAE;AACF,8CAA8C;AAC9C,4CAA4C;AAC5C,oDAAoD;AACpD,kEAAkE;AAClE,UAAU;AACV,EAAE;AACF,4CAA4C;AAC5C,EAAE;AACF,uCAAuC;AACvC,sDAAsD;AACtD,8CAA8C;AAC9C,OAAO,EAAE,SAAS,EAAE,MAAM,4BAA4B,CAAC;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AASvD;qCACqC;AACrC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,GAAG,GAAG,EAAE,EAAa,EAAE;IACrD,MAAM,KAAK,GAAG,IAAI,GAAG,EAAoB,CAAC;IAC1C,OAAO;QACL,IAAI,EAAE,CAAC,QAAQ,EAAE,IAAI,EAAE,EAAE;YACvB,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE;gBAAE,OAAO;YACzB,KAAK,MAAM,CAAC,IAAI,IAAI,GAAG,CAAC,QAAQ,CAAC,EAAE,CAAC;gBAClC,KAAK,CAAC,GAAG,CAAC,CAAC,EAAE,CAAC,GAAG,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;YAC5D,CAAC;QACH,CAAC;QACD,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE;YAChB,MAAM,KAAK,GAAG,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,EAAE,CAAC;YACtC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YACrB,OAAO,KAAK,CAAC;QACf,CAAC;KACF,CAAC;AACJ,CAAC,CAAC;AAEF,wDAAwD;AACxD,4CAA4C;AAC5C,8CAA8C;AAC9C,IAAI,KAA4B,CAAC;AACjC,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,GAAc,EAAa,EAAE,CAAC,CAAC,KAAK,GAAG,GAAG,CAAC,CAAC;AAC1E,MAAM,CAAC,MAAM,SAAS,GAAG,GAA0B,EAAE,CAAC,KAAK,CAAC;AAE5D,8DAA8D;AAC9D,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,KAAwB,EAAU,EAAE;IAChE,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAClC,OAAO,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE;QAC1C,2BAA2B;QAC3B,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC;QAC7B,qDAAqD;QACrD,2CAA2C;KAC5C,CAAC,CAAC;AACL,CAAC,CAAC;AAEF;;gBAEgB;AAChB,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,MAAc,EAAE,IAAY,EAAU,EAAE;IACtE,MAAM,GAAG,GAAG,SAAS,EAAE,CAAC;IACxB,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,SAAS,EAAE,CAAC,UAAU,CAAC,GAAG,CAAC;QAAE,OAAO,EAAE,CAAC;IACxD,OAAO,aAAa,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC;AAC1C,CAAC,CAAC;AAEF;oDACoD;AACpD,MAAM,CAAC,MAAM,YAAY,GAAG,CAC1B,WAA8B,EAC9B,IAAY,EACZ,MAAyB,EACf,EAAE,CACZ,WAAW,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,CAAC"}
|
package/dist/daemon/peers.js
CHANGED
|
@@ -11,6 +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";
|
|
15
|
+
import { envelopeAttrs, parseEnvelope, renderReminder } from "../shared/reminder.js";
|
|
14
16
|
/** Strip ANSI SGR/CSI + OSC so captured pane text is safe to embed / match on. */
|
|
15
17
|
export const stripAnsi = (s) => s.replace(/\x1B\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1B\][^\x07]*\x07/g, "");
|
|
16
18
|
// ── Transcript tail ───────────────────────────────────────────────────
|
|
@@ -70,13 +72,15 @@ const readTailUntil = (jsonlPath, parse, enough) => {
|
|
|
70
72
|
};
|
|
71
73
|
// Meta wrappers Claude Code injects around slash commands / hook output. They
|
|
72
74
|
// are machinery, not conversation — drop them before any summary or handoff.
|
|
73
|
-
const META_RE = /<(system-reminder|command-[
|
|
75
|
+
const META_RE = /<(system-reminder|command-[^>\s]*|local-command-[^>\s]*|task-notification)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g;
|
|
74
76
|
// Claude Code (2.1.27x+) 把折叠成 `[Pasted text #N]` 的粘贴在 transcript 里包成
|
|
75
77
|
// `<pasted_content id="…">…</pasted_content id="…">`。wezard 的注入走 tmux paste, 所以
|
|
76
78
|
// **每一条多行注入** (人在企微里说的、同伴派的、定时任务放的) 落盘时都带着这层壳。
|
|
77
79
|
// 壳是传输的产物不是内容: 只剥标签, 里面的话原样留下。
|
|
78
80
|
const PASTED_TAG_RE = /<pasted_content id="[^"]*">\n?|\n?<\/pasted_content(?:\s+id="[^"]*")?>/g;
|
|
79
81
|
export const unwrapPasted = (s) => s.replace(PASTED_TAG_RE, "");
|
|
82
|
+
/** 一段落盘的输入 → 人真正说的那句话: 去粘贴壳, 去信封 / 名册增量 / slash 包装。 */
|
|
83
|
+
export const stripMeta = (s) => unwrapPasted(s).replace(META_RE, "");
|
|
80
84
|
const blockText = (content) => {
|
|
81
85
|
if (typeof content === "string")
|
|
82
86
|
return content;
|
|
@@ -90,6 +94,39 @@ const blockText = (content) => {
|
|
|
90
94
|
/** Last `n` user/assistant turns of a transcript, oldest first. Backend-agnostic:
|
|
91
95
|
* the line shape is normalized through the owning CLI's dialect adapter. */
|
|
92
96
|
export const tailTurns = (jsonlPath, n = 3, keepLines = false) => readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, keepLines), (ts) => ts.length >= n).slice(-n);
|
|
97
|
+
/** `tailTurns` 去掉保温 ping/pong 之后的最后 `n` 轮 —— 凡是交给另一个 wizard (或人)
|
|
98
|
+
* **读**的都走这里: 挂机一晚的会话尾巴上全是 ping/pong, 照 tailTurns 数出来的
|
|
99
|
+
* 「最近 6 轮」一句真话都没有, 「最后一条回复」是一个 pong。`pingSigs` 只给保温
|
|
100
|
+
* 那一种 —— resumePing ("continue") 之后模型是真的在干活, 那一轮不能丢。 */
|
|
101
|
+
export const talkTurns = (jsonlPath, n, pingSigs = [], keepLines = false) => {
|
|
102
|
+
const clean = (ts) => withoutKeepalive(ts, pingSigs);
|
|
103
|
+
return clean(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, keepLines), (ts) => clean(ts).length >= n)).slice(-n);
|
|
104
|
+
};
|
|
105
|
+
/** 一个会话的来回, 每个来回 = 一句问话 + 它之后的全部回答 (途中的话在前, 终句在
|
|
106
|
+
* 最后), 旧的在前。读多深由时间窗定, 调用方再按时刻与条数裁:
|
|
107
|
+
* 给了 `since` → 一直读到比它更早的那一句 (窗口内的一句不漏);
|
|
108
|
+
* 没给 → 读到 `until` 之前攒够 `rounds` 个来回为止。
|
|
109
|
+
* 多读一个问话才停, 所以留下的最早一组是完整的; 开头那组若没有问话 (问话落在
|
|
110
|
+
* 读到的范围之外) 就丢掉 —— 一句不知道在答谁的话没法归到任何频道。 */
|
|
111
|
+
export const talkRounds = (jsonlPath, rounds, pingSigs = [], win = {}) => {
|
|
112
|
+
const { since, until = Infinity } = win;
|
|
113
|
+
const clean = (ts) => withoutKeepalive(ts, pingSigs);
|
|
114
|
+
const enough = (ts) => {
|
|
115
|
+
const seen = clean(ts).filter((t) => (t.ms ?? 0) < until);
|
|
116
|
+
return since !== undefined
|
|
117
|
+
? seen.length > 0 && (seen[0].ms ?? 0) < since
|
|
118
|
+
: seen.filter((t) => t.role === "user").length > rounds;
|
|
119
|
+
};
|
|
120
|
+
return clean(readTailUntil(jsonlPath, (raw) => parseTurns(jsonlPath, raw, true), enough))
|
|
121
|
+
.reduce((acc, t) => {
|
|
122
|
+
if (t.role === "user" || acc.length === 0)
|
|
123
|
+
acc.push([t]);
|
|
124
|
+
else
|
|
125
|
+
acc[acc.length - 1].push(t);
|
|
126
|
+
return acc;
|
|
127
|
+
}, [])
|
|
128
|
+
.filter((round) => round[0].role === "user");
|
|
129
|
+
};
|
|
93
130
|
/** 纯解析: 一段 transcript 原文 → 里面的对话轮次。`keepLines` 保留换行 —— 摘要与
|
|
94
131
|
* 预览要压成一行, 但交给另一个 wizard 读的回复不能: 表格 / 列表 / 代码块压成一行
|
|
95
132
|
* 之后对模型就是一团字。 */
|
|
@@ -122,7 +159,9 @@ const parseTurns = (jsonlPath, raw, keepLines = false) => {
|
|
|
122
159
|
const role = row.message?.role;
|
|
123
160
|
if (role !== "user" && role !== "assistant")
|
|
124
161
|
return [];
|
|
125
|
-
const
|
|
162
|
+
const raw = blockText(row.message?.content);
|
|
163
|
+
const bare = stripMeta(raw);
|
|
164
|
+
const env = role === "user" ? parseEnvelope(raw) : undefined;
|
|
126
165
|
const text = (keepLines ? bare : bare.replace(/\s+/g, " ")).trim();
|
|
127
166
|
// Claude writes ISO timestamp strings; CodeBuddy writes epoch-ms NUMBERS.
|
|
128
167
|
// Date.parse(number) coerces to a bare digit-string and returns NaN —
|
|
@@ -132,7 +171,7 @@ const parseTurns = (jsonlPath, raw, keepLines = false) => {
|
|
|
132
171
|
// the round budget without bound.
|
|
133
172
|
const rawTs = parsed.timestamp;
|
|
134
173
|
const ms = typeof rawTs === "number" ? rawTs : Date.parse(String(rawTs ?? ""));
|
|
135
|
-
return text ? [{ role, text, ms: Number.isNaN(ms) ? 0 : ms }] : [];
|
|
174
|
+
return text ? [{ role, text, ms: Number.isNaN(ms) ? 0 : ms, ...(env ? { env } : {}) }] : [];
|
|
136
175
|
});
|
|
137
176
|
};
|
|
138
177
|
// Flatten a message's content for QUOTE DEDUP only — unlike `blockText` this
|
|
@@ -171,10 +210,29 @@ const normPing = (s) => s.replace(/\s+/gu, "");
|
|
|
171
210
|
/** Normalized signature set of the configured ping forms — whitespace-stripped
|
|
172
211
|
* 40-char prefixes, the same shape keepaliveTick feeds keepaliveStamps. */
|
|
173
212
|
export const keepalivePingSigs = (...pings) => pings.map((p) => normPing(p).slice(0, 40)).filter((s) => s.length > 0);
|
|
213
|
+
/** A sig at the cut length is a prefix of a longer ping; a shorter one IS the whole ping. */
|
|
214
|
+
const SIG_LEN = 40;
|
|
174
215
|
/** Is this user-turn text a keepalive ping? Matches every configured form plus
|
|
175
|
-
* the bare "ping" legacy streak form still present in older transcripts.
|
|
176
|
-
|
|
177
|
-
|
|
216
|
+
* the bare "ping" legacy streak form still present in older transcripts.
|
|
217
|
+
* Anchored, never a substring: `resumePing` is a plain "continue", and a peer's
|
|
218
|
+
* "continue(接着干原任务)" or a human's "continue" is real work — read as a ping it
|
|
219
|
+
* gets swallowed whole (no reply to the chat, no `from` on the rolepage). */
|
|
220
|
+
export const isKeepalivePingText = (text, sigs) => {
|
|
221
|
+
const n = normPing(text);
|
|
222
|
+
return n.toLowerCase() === "ping" ||
|
|
223
|
+
sigs.some((sig) => sig.length > 0 && (sig.length < SIG_LEN ? n === sig : n.startsWith(sig)));
|
|
224
|
+
};
|
|
225
|
+
/** Keepalive = the ping query + everything the model says back until the next
|
|
226
|
+
* user turn — query-based, so a reply that adds more than "pong" still goes,
|
|
227
|
+
* and a backend that splits one reply across several records (CodeBuddy:
|
|
228
|
+
* mid-turn narration + final) loses all of them, not just the first. A window
|
|
229
|
+
* that opens on a bare "pong" lost its ping to the cut; that one goes too. */
|
|
230
|
+
export const withoutKeepalive = (turns, pingSigs) => turns.reduce((acc, t) => {
|
|
231
|
+
const ping = t.role === "user" ? isKeepalivePingText(t.text, pingSigs) : acc.ping;
|
|
232
|
+
if (!ping)
|
|
233
|
+
acc.kept.push(t);
|
|
234
|
+
return { kept: acc.kept, ping };
|
|
235
|
+
}, { kept: [], ping: turns[0]?.role === "assistant" && /^pong\W*$/i.test(turns[0].text.trim()) }).kept;
|
|
178
236
|
/** The keepalive ping this session's newest words answer: the text of the LAST
|
|
179
237
|
* user turn when that turn is a ping, else undefined. Read from the transcript
|
|
180
238
|
* on purpose — "was a keepalive sent" must survive whatever forgot the
|
|
@@ -213,16 +271,10 @@ const parseToolEntries = (jsonlPath, raw) => {
|
|
|
213
271
|
const role = row.message?.role;
|
|
214
272
|
if (role !== "user" && role !== "assistant")
|
|
215
273
|
return [];
|
|
216
|
-
const text =
|
|
274
|
+
const text = stripMeta(blockTextWithTools(row.message?.content)).replace(/\s+/g, " ").trim();
|
|
217
275
|
return text ? [{ role, text }] : [];
|
|
218
276
|
});
|
|
219
277
|
};
|
|
220
|
-
/** Keepalive = ping query + whatever the model replies to it (query-based,
|
|
221
|
-
* same rule as keepaliveStamps). */
|
|
222
|
-
const withoutPings = (all, pingSigs) => {
|
|
223
|
-
const isPing = (e) => !!e && e.role === "user" && isKeepalivePingText(e.text, pingSigs);
|
|
224
|
-
return all.filter((e, i) => !isPing(e) && !(e.role === "assistant" && isPing(all[i - 1])));
|
|
225
|
-
};
|
|
226
278
|
/** 逻辑轮次 = 角色交替的次数 (一条模型回复可能被后端拆成好几行记录)。 */
|
|
227
279
|
const countTurns = (entries) => entries.reduce((acc, e, i) => acc + (i > 0 && entries[i - 1].role !== e.role ? 1 : 0), 0);
|
|
228
280
|
/** 从尾部数 `n` 个逻辑轮次的切点; 不足 n 轮就从头给 (要多少给多少)。 */
|
|
@@ -235,7 +287,7 @@ const cutForTurns = (entries, n) => {
|
|
|
235
287
|
return 0;
|
|
236
288
|
};
|
|
237
289
|
export const tailTurnsWithTools = (jsonlPath, n = 3, pingSigs = []) => {
|
|
238
|
-
const entries =
|
|
290
|
+
const entries = withoutKeepalive(readTailUntil(jsonlPath, (raw) => parseToolEntries(jsonlPath, raw), (es) => countTurns(withoutKeepalive(es, pingSigs)) >= n), pingSigs);
|
|
239
291
|
return entries.slice(cutForTurns(entries, n)).map((e) => e.text).join("\n");
|
|
240
292
|
};
|
|
241
293
|
// Transcript prose is arbitrary text: backticks / asterisks / pipes lifted out of
|
|
@@ -243,22 +295,92 @@ export const tailTurnsWithTools = (jsonlPath, n = 3, pingSigs = []) => {
|
|
|
243
295
|
// layout. A preview is plain text — flatten every markdown-active char.
|
|
244
296
|
const stripMd = (s) => s.replace(/[`*_~|]/g, "").replace(/\s+/g, " ").trim();
|
|
245
297
|
/** One-line "what is this session doing" preview, for list rendering. */
|
|
246
|
-
export const summarizeTail = (jsonlPath, n = 3, per = 80) => {
|
|
298
|
+
export const summarizeTail = (jsonlPath, n = 3, per = 80, pingSigs = []) => {
|
|
247
299
|
if (!existsSync(jsonlPath))
|
|
248
300
|
return "(新会话 · 暂无对话)";
|
|
249
|
-
const turns =
|
|
301
|
+
const turns = talkTurns(jsonlPath, n, pingSigs);
|
|
250
302
|
if (turns.length === 0)
|
|
251
303
|
return "(暂无对话)";
|
|
252
|
-
|
|
304
|
+
// 截了就得看得出截了: 裸 slice 出来的半句话, 读的人会当成它就说了这么多。
|
|
305
|
+
return turns.map((t) => `${t.role === "user" ? "你" : "AI"}: ${truncate(stripMd(t.text), per)}`).join(" · ");
|
|
306
|
+
};
|
|
307
|
+
/** 最近一个来回, 压成一行: `▸ 问话 ◂ 它最新的一句`。名册用它 —— 读名册的是另一个
|
|
308
|
+
* wizard, summarizeTail 的「你:」在它眼里指的是它自己; 这里用 peek_peer 同款的
|
|
309
|
+
* 箭头, 不指代任何人。没有对话 = ""。 */
|
|
310
|
+
export const lastExchange = (jsonlPath, per = 80, pingSigs = []) => {
|
|
311
|
+
const round = talkRounds(jsonlPath, 1, pingSigs).at(-1);
|
|
312
|
+
if (!round)
|
|
313
|
+
return "";
|
|
314
|
+
const line = (t) => truncate(stripMd(t.text), per);
|
|
315
|
+
return [`▸ ${line(round[0])}`, ...(round.length > 1 ? [`◂ ${line(round.at(-1))}`] : [])].join(" ");
|
|
316
|
+
};
|
|
317
|
+
/** 这个会话最近一次应答用的模型 —— transcript 里记的是真正跑的那个, 而绑定里的
|
|
318
|
+
* `model` 只在有人显式选过时才有 (用 CLI 默认模型的会话那里是空的)。 */
|
|
319
|
+
export const lastModel = (jsonlPath) => readTailUntil(jsonlPath, (raw) => pickModel(jsonlPath, raw), (v) => v !== "");
|
|
320
|
+
const pickModel = (jsonlPath, raw) => {
|
|
321
|
+
const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
|
|
322
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
323
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
324
|
+
let row;
|
|
325
|
+
try {
|
|
326
|
+
row = normalize(JSON.parse(lines[i]));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const model = row?.message?.role === "assistant" ? row.message.model ?? "" : "";
|
|
332
|
+
// `<synthetic>` 是 CLI 自己写的报错行 (限流 / 断连), 不是模型。
|
|
333
|
+
if (model && !model.startsWith("<"))
|
|
334
|
+
return model;
|
|
335
|
+
}
|
|
336
|
+
return "";
|
|
253
337
|
};
|
|
254
338
|
/** Readable multi-turn rendering of a transcript tail — "what has been said in
|
|
255
339
|
* that session", for one agent reading another's conversation. Strictly better
|
|
256
340
|
* than a pane capture for *reading*: whole messages (the viewport truncates),
|
|
257
341
|
* no ANSI / TUI chrome, already role-tagged. The pane remains the only honest
|
|
258
|
-
* source for `busy`.
|
|
259
|
-
|
|
260
|
-
|
|
342
|
+
* source for `busy`. The newest turn gets `lastPer` — it is the one a reader
|
|
343
|
+
* came for (the peer's latest answer), so it is clipped last and in the middle. */
|
|
344
|
+
export const renderDialog = (turns, per = 800, lastPer = per) => turns
|
|
345
|
+
.map((t, i) => `${t.role === "user" ? "▸" : "◂"} ${i === turns.length - 1 ? clipMiddle(t.text, Math.max(per, lastPer)) : truncateWithCount(t.text, per)}`)
|
|
261
346
|
.join("\n");
|
|
347
|
+
/** 这个会话此刻悬着的工具调用 (发了 tool_use、还没等到 tool_result) 的名字。
|
|
348
|
+
*
|
|
349
|
+
* peek 从前靠刮终端回答「它卡在哪」; transcript 答得更准: 一个不在转圈、却悬着
|
|
350
|
+
* 工具调用的会话, 就是停在审批卡 / 本地弹窗上等人点。一句新的人话会让没收到结果
|
|
351
|
+
* 的调用作废 (被打断的那些), 所以遇到带正文的 user 行就清空。 */
|
|
352
|
+
export const openToolUses = (jsonlPath) => readTailUntil(jsonlPath, (raw) => parseOpenTools(jsonlPath, raw), (r) => r.rows > 0).names;
|
|
353
|
+
const parseOpenTools = (jsonlPath, raw) => {
|
|
354
|
+
const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
|
|
355
|
+
const open = new Map();
|
|
356
|
+
let rows = 0;
|
|
357
|
+
for (const line of raw.split("\n")) {
|
|
358
|
+
if (!line.trim())
|
|
359
|
+
continue;
|
|
360
|
+
let row;
|
|
361
|
+
try {
|
|
362
|
+
row = normalize(JSON.parse(line));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const content = row?.message?.content;
|
|
368
|
+
if (!row || row.isMeta || row.isSidechain || !content)
|
|
369
|
+
continue;
|
|
370
|
+
rows++;
|
|
371
|
+
if (row.message?.role === "user" && stripMeta(blockText(content)).trim())
|
|
372
|
+
open.clear();
|
|
373
|
+
if (!Array.isArray(content))
|
|
374
|
+
continue;
|
|
375
|
+
for (const b of content) {
|
|
376
|
+
if (b.type === "tool_use" && b.id)
|
|
377
|
+
open.set(b.id, b.name ?? "?");
|
|
378
|
+
if (b.type === "tool_result" && b.tool_use_id)
|
|
379
|
+
open.delete(b.tool_use_id);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { names: [...open.values()], rows };
|
|
383
|
+
};
|
|
262
384
|
/** 交付收口行。
|
|
263
385
|
*
|
|
264
386
|
* fan-out 之后 join 的载荷是「最后一条 assistant 文本」, 而一个分身的最后一句可能
|
|
@@ -276,11 +398,7 @@ export const extractResult = (text, max = 800) => {
|
|
|
276
398
|
};
|
|
277
399
|
/** The peer's most recent assistant message — the handoff payload when one
|
|
278
400
|
* agent drives another ("take #fix's conclusion and review it"). */
|
|
279
|
-
export const lastAssistantText = (jsonlPath, max = 4000) =>
|
|
280
|
-
const turns = tailTurns(jsonlPath, 40).filter((t) => t.role === "assistant");
|
|
281
|
-
const last = turns[turns.length - 1]?.text ?? "";
|
|
282
|
-
return last.length > max ? `${last.slice(0, max)}…` : last;
|
|
283
|
-
};
|
|
401
|
+
export const lastAssistantText = (jsonlPath, max = 4000, pingSigs = []) => truncate(talkTurns(jsonlPath, 40, pingSigs).filter((t) => t.role === "assistant").at(-1)?.text ?? "", max);
|
|
284
402
|
/** 掐中间: 来龙去脉在头、结论在尾, 超长时丢的该是中段。(从尾巴截会把收口那一行
|
|
285
403
|
* 截掉 —— 而那恰恰是对方最想读的。) */
|
|
286
404
|
export const clipMiddle = (s, max = 4000, head = Math.floor(max / 4)) => s.length <= max ? s : `${s.slice(0, head)}\n…(略 ${s.length - max} 字)…\n${s.slice(head - max)}`;
|
|
@@ -289,8 +407,9 @@ export const clipMiddle = (s, max = 4000, head = Math.floor(max / 4)) => s.lengt
|
|
|
289
407
|
* 「最后一条 assistant 文本」本身不带归属: 那句话没被接住、或者它停在一个等人点
|
|
290
408
|
* 的弹窗上时, 最后一条是**上一件事**的答案, 原样交回去就是拿旧结论冒充新结论。
|
|
291
409
|
* 发话时刻是最便宜的关联 id (A2A 的 taskId 在这里的等价物): 早于它的一律不算。
|
|
292
|
-
* 没有时间戳的行 (ms=0) 无从判断, 放行 —— 退化成旧行为, 而不是把回复吞掉。
|
|
293
|
-
|
|
410
|
+
* 没有时间戳的行 (ms=0) 无从判断, 放行 —— 退化成旧行为, 而不是把回复吞掉。
|
|
411
|
+
* 保温的 pong 不算回复: 它比真正的答案晚, 不剔掉的话等的人拿到的就是一个 "pong"。 */
|
|
412
|
+
export const lastReply = (jsonlPath, sinceMs = 0, pingSigs = []) => talkTurns(jsonlPath, 40, pingSigs, true)
|
|
294
413
|
.filter((t) => t.role === "assistant" && (!t.ms || t.ms >= sinceMs))
|
|
295
414
|
.at(-1)?.text ?? "";
|
|
296
415
|
// ── wizard → wizard 的信封 ────────────────────────────────────────────
|
|
@@ -301,23 +420,30 @@ export const lastReply = (jsonlPath, sinceMs = 0) => tailTurns(jsonlPath, 40, tr
|
|
|
301
420
|
// 自己身上: 宪章是出生时的快照, 正在跑的老 wizard 读不到新规矩, 这一段读得到。
|
|
302
421
|
// 与 mention hint / 名册增量同一个 `<system-reminder>` 壳 —— mirror 的 meta 剥离器
|
|
303
422
|
// 和 tailTurns 都会丢掉它, 不进气泡、不进 rolepage、不进 peek。
|
|
423
|
+
//
|
|
424
|
+
// 信封同时是 transcript 里**唯一**记着「这句话是谁、在哪个频道说的」的地方: 会话的
|
|
425
|
+
// jsonl 只知道输入框里进过什么。read_chat 就靠把它读回来 (parseEnvelope) 在各个
|
|
426
|
+
// 会话的 jsonl 之间拼出一个群 / 一段私聊的记录。事实写在开标签的属性上 (见
|
|
427
|
+
// shared/reminder.ts), 正文只是给模型的规矩 —— 改措辞不影响解析; 老措辞在那边认。
|
|
428
|
+
export { parseEnvelope };
|
|
429
|
+
/** 人说的那一轮的信封。只在 transcript 自己推不出来时才挂 (见 inbound.send): 一个
|
|
430
|
+
* wizard 被人从别的群叫到、或者住在多人的群里, 它和后来读记录的人都得知道这句是
|
|
431
|
+
* 谁、在哪说的; 住在与一个人的单聊里的, 默认值就是对的, 不为它每轮多付一段。 */
|
|
432
|
+
export const renderHumanEnvelope = (user, chat) => renderReminder(envelopeAttrs.human(user, chat), [`这一轮是 \`${user}\` 在群 **${chat}** 里说的, 你的回复发回那个群。`]);
|
|
433
|
+
/** 定时任务放的那一轮: 不标的话, 记录里它就成了「人说的」。 */
|
|
434
|
+
export const renderTaskEnvelope = (taskId) => renderReminder(envelopeAttrs.task(taskId), [`这一轮是定时任务 \`${taskId}\` 到点放进来的, 不是人此刻说的 —— 照常执行, 回复照常发进群。`]);
|
|
304
435
|
/** `from` = 发话方的称呼 (`.name`); `chat` 给了 = 公开轮 (那个群的名字, 可以是 ""),
|
|
305
436
|
* 不给 = 私聊。 */
|
|
306
|
-
export const renderPeerEnvelope = (from, chat) =>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
`这一轮是 wizard \`${from}\` 在群${chat ? ` **${chat}** ` : ""}里**公开**对你说的: 你的回复会直接发进那个群, 人和 \`${from}\` 都看得到。`,
|
|
317
|
-
`照对人说话的方式答; 不要再 send_peer 把同一段话回给 \`${from}\`。`,
|
|
318
|
-
]),
|
|
319
|
-
"</system-reminder>",
|
|
320
|
-
].join("\n");
|
|
437
|
+
export const renderPeerEnvelope = (from, chat) => renderReminder(envelopeAttrs.peer(from, chat), chat === undefined
|
|
438
|
+
? [
|
|
439
|
+
`这一轮是 wizard \`${from}\` 发来的**私聊**, 不是人说的: 人看不见这一轮, 读你回复的是 \`${from}\`。`,
|
|
440
|
+
`你这一轮的**最后一条消息**就是给它的回执 —— 它用 wait_peer 取, 所以不要再 send_peer 回它 (除非它明说干完通知它), 也不要为这一轮 notify。`,
|
|
441
|
+
"对 wizard 直说: 不寒暄、不加对人的称呼、不复述它的话; 结论收口成末尾的 `RESULT: …` (交付物写进文件就回传路径), 要它补信息也写在那里。",
|
|
442
|
+
]
|
|
443
|
+
: [
|
|
444
|
+
`这一轮是 wizard \`${from}\` 在群${chat ? ` **${chat}** ` : ""}里**公开**对你说的: 你的回复会直接发进那个群, 人和 \`${from}\` 都看得到。`,
|
|
445
|
+
`照对人说话的方式答; 不要再 send_peer 把同一段话回给 \`${from}\`。`,
|
|
446
|
+
]);
|
|
321
447
|
/** Prompt-token size of the session's most recent turn: input + both cache
|
|
322
448
|
* tiers = how full the context window is, i.e. exactly what a cold cache would
|
|
323
449
|
* have to re-write at 1.25x. Read from the last assistant usage snapshot in
|
|
@@ -363,6 +489,10 @@ const pickContextTokens = (jsonlPath, raw) => {
|
|
|
363
489
|
* warmth clock; only a real model request actually re-warms the cache.
|
|
364
490
|
* `lastRealMs` newest GENUINE turn (non-keepalive) — the real-idle cutoff; 0
|
|
365
491
|
* when none sits in the read window ⇒ real work predates the tail.
|
|
492
|
+
* `streak` pings already sent since that last genuine turn — the round
|
|
493
|
+
* counter's seed. The budget is "N pings after real work", a fact
|
|
494
|
+
* of the transcript: a counter that lives only in memory restarts
|
|
495
|
+
* at 0 on every daemon reload and hands the session a fresh N.
|
|
366
496
|
* `stamped` false when no turn carried a timestamp (backend without them), so
|
|
367
497
|
* the caller can fall back to mtime instead of judging all idle. */
|
|
368
498
|
export const keepaliveStamps = (jsonlPath, pingSigs) => {
|
|
@@ -372,34 +502,47 @@ export const keepaliveStamps = (jsonlPath, pingSigs) => {
|
|
|
372
502
|
// prefixes of each) plus the bare "ping" that streak pings used to shrink to
|
|
373
503
|
// (transcripts written before that changed still hold them) — otherwise a
|
|
374
504
|
// keepalive user line reads as REAL activity and re-anchors lastRealMs /
|
|
375
|
-
// resets the round counter before the model has even replied.
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
505
|
+
// resets the round counter before the model has even replied. What counts as
|
|
506
|
+
// keepalive (the ping AND every record of its reply) is `withoutKeepalive`'s
|
|
507
|
+
// rule — one definition for the clocks, the quote dedup and the peer readers.
|
|
508
|
+
const newest = (ts) => ts.reduce((m, t) => Math.max(m, t.ms ?? 0), 0);
|
|
509
|
+
const real = withoutKeepalive(turns, pingSigs);
|
|
510
|
+
// Everything past the last genuine turn is keepalive; its user turns are the pings.
|
|
511
|
+
const sinceReal = real.length ? turns.slice(turns.lastIndexOf(real[real.length - 1]) + 1) : turns;
|
|
512
|
+
// Turns are text-only, so a long tool chain after the last prose line would
|
|
513
|
+
// read as idle — the tool rows are real work and each one touched the cache.
|
|
514
|
+
// A ping's reply is a bare "pong", never a tool call, so they can't be warmer traffic.
|
|
515
|
+
const work = lastToolMs(jsonlPath);
|
|
516
|
+
const pingsAfterWork = work > newest(sinceReal) ? [] : sinceReal;
|
|
517
|
+
return {
|
|
518
|
+
lastMs: Math.max(newest(turns), work),
|
|
519
|
+
lastRealMs: Math.max(newest(real), work),
|
|
520
|
+
streak: pingsAfterWork.filter((t) => t.role === "user").length,
|
|
521
|
+
stamped: turns.some((t) => (t.ms ?? 0) > 0),
|
|
522
|
+
};
|
|
523
|
+
};
|
|
524
|
+
/** Timestamp of the newest main-thread tool_use / tool_result row in the tail (0 = none). */
|
|
525
|
+
const lastToolMs = (jsonlPath) => {
|
|
526
|
+
const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
|
|
527
|
+
return readTailBytes(jsonlPath, TAIL_BYTES)
|
|
528
|
+
.split("\n")
|
|
529
|
+
.reduce((m, line) => {
|
|
530
|
+
if (!line.includes("tool_"))
|
|
531
|
+
return m; // cheap prefilter
|
|
532
|
+
try {
|
|
533
|
+
const parsed = JSON.parse(line);
|
|
534
|
+
const row = normalize(parsed);
|
|
535
|
+
if (!row || row.isMeta || row.isSidechain)
|
|
536
|
+
return m;
|
|
537
|
+
const content = row.message?.content;
|
|
538
|
+
const tooly = Array.isArray(content) && content.some((b) => /^tool_(use|result)$/.test(b?.type ?? ""));
|
|
539
|
+
const ms = typeof parsed.timestamp === "number" ? parsed.timestamp : Date.parse(String(parsed.timestamp ?? ""));
|
|
540
|
+
return tooly && !Number.isNaN(ms) ? Math.max(m, ms) : m;
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return m;
|
|
544
|
+
}
|
|
545
|
+
}, 0);
|
|
403
546
|
};
|
|
404
547
|
// ── Pane liveness ─────────────────────────────────────────────────────
|
|
405
548
|
// "Is this agent still working?" answered from outside the process. The pane is
|
|
@@ -416,7 +559,12 @@ export const keepaliveStamps = (jsonlPath, pingSigs) => {
|
|
|
416
559
|
// Only the footer region is searched (last few non-blank rows). Assistant prose
|
|
417
560
|
// scrolled just above the box can quote an elapsed time; the footer cannot lie.
|
|
418
561
|
const SPINNER_RE = /\S*…\s*\((?:\d+h\s*)?(?:\d+m\s*)?\d+(?:\.\d+)?s\b/;
|
|
419
|
-
|
|
562
|
+
// The CLI's own auto-retry footer (`✻ API error · Retrying in 0s · attempt 2/10`)
|
|
563
|
+
// has neither an ellipsis nor a timer parenthetical, yet the turn is still in
|
|
564
|
+
// flight — read as idle it both lets keepalive in and trips paneIsStalled's
|
|
565
|
+
// /API Error/, so a healthy session gets a stray "continue" queued behind the retry.
|
|
566
|
+
const RETRY_RE = /Retrying in \d+(?:\.\d+)?s\b.*attempt \d+\/\d+/i;
|
|
567
|
+
const BUSY_MARKERS = [SPINNER_RE, RETRY_RE, /esc to interrupt/i, /按\s*esc[^\n]*中断/, /esc\s*中断/i];
|
|
420
568
|
const FOOTER_ROWS = 8;
|
|
421
569
|
export const paneIsBusy = (paneText) => stripAnsi(paneText)
|
|
422
570
|
.split("\n")
|
|
@@ -424,58 +572,48 @@ export const paneIsBusy = (paneText) => stripAnsi(paneText)
|
|
|
424
572
|
.filter(Boolean)
|
|
425
573
|
.slice(-FOOTER_ROWS)
|
|
426
574
|
.some((l) => BUSY_MARKERS.some((re) => re.test(l)));
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
// of the definition: a live auto-retry spinner is the CLI already recovering, so
|
|
431
|
-
// we stay out of its way. Searched over a wider window than the spinner — the
|
|
432
|
-
// error text can sit a few rows above the reclaimed input box.
|
|
433
|
-
const STALL_MARKERS = [
|
|
434
|
-
/API Error/i, /request (failed|timed ?out)/i, /overloaded/i, /server error/i,
|
|
435
|
-
/rate.?limit/i, /usage limit/i, /too many requests/i, /quota/i,
|
|
436
|
-
/请求过多/, /限流|超过.{0,4}限制|额度不足|额度已/, /接口.{0,4}(失败|错误|超时)/, /连接(超时|中断|失败)/, /稍后.{0,4}重试/,
|
|
437
|
-
];
|
|
438
|
-
const STALL_ROWS = 16;
|
|
439
|
-
export const paneIsStalled = (paneText) => {
|
|
440
|
-
if (paneIsBusy(paneText))
|
|
441
|
-
return false;
|
|
442
|
-
return stripAnsi(paneText)
|
|
575
|
+
const stallRows = (jsonlPath) => {
|
|
576
|
+
const normalize = backendForPath(jsonlPath).normalizeTranscriptLine;
|
|
577
|
+
return readTailBytes(jsonlPath, TAIL_BYTES)
|
|
443
578
|
.split("\n")
|
|
444
|
-
.
|
|
445
|
-
.
|
|
446
|
-
|
|
447
|
-
|
|
579
|
+
.flatMap((line) => {
|
|
580
|
+
if (!line.trim())
|
|
581
|
+
return [];
|
|
582
|
+
try {
|
|
583
|
+
const parsed = JSON.parse(line);
|
|
584
|
+
const row = normalize(parsed);
|
|
585
|
+
const role = row?.message?.role;
|
|
586
|
+
if (!row || row.isMeta || row.isSidechain || (role !== "user" && role !== "assistant"))
|
|
587
|
+
return [];
|
|
588
|
+
const content = row.message?.content;
|
|
589
|
+
const blocks = Array.isArray(content) ? content : [];
|
|
590
|
+
const ms = typeof parsed.timestamp === "number" ? parsed.timestamp : Date.parse(String(parsed.timestamp ?? ""));
|
|
591
|
+
return [{
|
|
592
|
+
role, ms: Number.isNaN(ms) ? 0 : ms,
|
|
593
|
+
apiError: parsed.isApiErrorMessage === true,
|
|
594
|
+
toolResult: blocks.length > 0 && blocks.every((b) => b?.type === "tool_result"),
|
|
595
|
+
text: stripMeta(blockText(content)).trim(),
|
|
596
|
+
}];
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return [];
|
|
600
|
+
} // truncated first line of the tail window
|
|
601
|
+
});
|
|
448
602
|
};
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
for (let i = turns.length - 1; i >= 0; i--) {
|
|
463
|
-
const t = turns[i];
|
|
464
|
-
if (t.role !== "assistant")
|
|
465
|
-
continue; // judge only the newest assistant turn
|
|
466
|
-
const txt = t.text.trim();
|
|
467
|
-
return txt.length > 0 && txt.length < 240 && STALL_TEXT_MARKERS.some((re) => re.test(txt));
|
|
468
|
-
}
|
|
469
|
-
return false;
|
|
603
|
+
export const transcriptStalled = (jsonlPath, pingSigs, quietMs, now = Date.now()) => {
|
|
604
|
+
const rows = stallRows(jsonlPath);
|
|
605
|
+
const last = rows.at(-1);
|
|
606
|
+
if (!last || !last.ms || now - last.ms < quietMs)
|
|
607
|
+
return false;
|
|
608
|
+
if (last.toolResult)
|
|
609
|
+
return true;
|
|
610
|
+
if (!(last.role === "assistant" && last.apiError))
|
|
611
|
+
return false;
|
|
612
|
+
if (/\/login\b|log(ged)? ?in|auth/i.test(last.text))
|
|
613
|
+
return false;
|
|
614
|
+
const opener = [...rows].reverse().find((r) => r.role === "user" && !r.toolResult);
|
|
615
|
+
return !!opener && !isKeepalivePingText(opener.text, pingSigs);
|
|
470
616
|
};
|
|
471
|
-
/** Trim a captured pane to its last `rows` non-blank lines — the TUI pads the
|
|
472
|
-
* viewport with empties that would otherwise dominate a WeCom bubble. */
|
|
473
|
-
export const compactPane = (paneText, rows = 24) => stripAnsi(paneText)
|
|
474
|
-
.split("\n")
|
|
475
|
-
.map((l) => l.replace(/\s+$/, ""))
|
|
476
|
-
.filter((l) => l.trim())
|
|
477
|
-
.slice(-rows)
|
|
478
|
-
.join("\n");
|
|
479
617
|
/** Machinery, not conversation: the `<system-reminder>` wrapper is the same
|
|
480
618
|
* marker the mirror's meta-stripper and `tailTurns` already drop, so the hint
|
|
481
619
|
* reaches the agent's context without leaking into WeCom bubbles or peer
|
|
@@ -485,16 +623,13 @@ export const renderPeerMentionHint = (mentions) => {
|
|
|
485
623
|
return "";
|
|
486
624
|
const lines = mentions.map((m) => `- \`.${m.tag}\` ${m.label} —— 一个活着的 wizard, 住在${m.foreign ? `**另一个**聊天${m.chat ? ` (\`${m.chat}\`)` : ""}` : "你这个聊天"}` +
|
|
487
625
|
` (target \`${m.target}\`${m.cwd ? `, 工作区 ${m.cwd}` : ""}), 地址 "${m.address}"。`);
|
|
488
|
-
return [
|
|
489
|
-
"",
|
|
490
|
-
"<system-reminder>",
|
|
626
|
+
return renderReminder({ wezard: "mention", names: mentions.map((m) => `.${m.tag}`).join(" ") }, [
|
|
491
627
|
"上面这条消息里的 `.name` 点的是**别的 wizard**, 不是字面文本:",
|
|
492
628
|
...lines,
|
|
493
629
|
"用户要你把它们拉进来、看看它们在干嘛、或者把话带到时, 去叫它们, 别猜、更别替它们回答:",
|
|
494
630
|
"peek_peer(address) 读它最近的对话, send_peer(address, text) 派活或推它一把, wait_peer(address) 等它闲下来,",
|
|
495
631
|
"wizard_roster() 看全体 wizard 的名字/职责/家谱。地址就是它的全局名字, 原样用上面给的那个。",
|
|
496
632
|
"只是提了一嘴、并没有要你去找它 (\".b 说的那个方案\") 就不必调工具 —— 看用户到底要什么。",
|
|
497
|
-
|
|
498
|
-
].join("\n");
|
|
633
|
+
]);
|
|
499
634
|
};
|
|
500
635
|
//# sourceMappingURL=peers.js.map
|