openclaw-lark-multi-agent 1.2.0 → 1.2.2

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.
@@ -0,0 +1,75 @@
1
+ /** Coarse phase of the compaction flow, drives the card's primary line. */
2
+ export type CompactPhase = "native" | "tool-trim";
3
+ /** Terminal outcome used by the renderer to pick color/emoji. */
4
+ export type CompactState = "running" | "done" | "failed" | "noop";
5
+ /** Structured snapshot the renderer turns into a Feishu interactive card. */
6
+ export type CompactProgressView = {
7
+ state: CompactState;
8
+ /** Current phase while running (ignored once terminal). */
9
+ phase: CompactPhase;
10
+ /** Elapsed time like "0:42". */
11
+ elapsed: string;
12
+ /** Terminal detail line, e.g. "12.3M→4.1M (-67%)" or an error/skip reason. */
13
+ detail?: string;
14
+ };
15
+ export type CompactProgressCallbacks = {
16
+ /** Create the status card; returns the new message id (undefined disables). */
17
+ create: (view: CompactProgressView) => Promise<string | undefined>;
18
+ /** Patch the existing status card. */
19
+ edit: (messageId: string, view: CompactProgressView) => Promise<void>;
20
+ warn?: (message: string, err?: unknown) => void;
21
+ };
22
+ export type CompactProgressOptions = {
23
+ locale?: "zh" | "en";
24
+ /** Delay before the card first appears, to avoid flashing on fast compactions. */
25
+ delayMs?: number;
26
+ /** Footer/auto-refresh cadence in ms. */
27
+ tickMs?: number;
28
+ };
29
+ /**
30
+ * Lifecycle: start() → (optional) toToolTrim() when native gives up → one of
31
+ * done()/noop()/fail(). The card is created lazily after `delayMs` so a fast
32
+ * compaction that finishes first never shows a card at all. All terminal calls
33
+ * are idempotent and safe even if the card was never created.
34
+ */
35
+ export declare class CompactProgressController {
36
+ private readonly callbacks;
37
+ private readonly opts;
38
+ private messageId?;
39
+ private startedAt;
40
+ private phase;
41
+ private state;
42
+ private detail?;
43
+ private createTimer?;
44
+ private tickTimer?;
45
+ private createPromise?;
46
+ private finalized;
47
+ private disabled;
48
+ private lastSignature;
49
+ constructor(callbacks: CompactProgressCallbacks, opts?: CompactProgressOptions);
50
+ get id(): string | undefined;
51
+ /** Begin the flow; schedules lazy card creation after delayMs. */
52
+ start(): void;
53
+ /** Native compaction gave up (timeout/no-op); switch the card to fast-trim. */
54
+ toToolTrim(): Promise<void>;
55
+ /** Finished successfully; patch the card to the done state with a detail line. */
56
+ done(detail?: string): Promise<void>;
57
+ /** Nothing to compact; patch the card to a neutral "no-op" state. */
58
+ noop(detail?: string): Promise<void>;
59
+ /** Failed; patch the card to the failed state with an error detail line. */
60
+ fail(detail?: string): Promise<void>;
61
+ /** Tear down timers without sending anything (e.g. on unexpected disposal). */
62
+ dispose(): void;
63
+ private finish;
64
+ private refresh;
65
+ private ensureCreatedNow;
66
+ private ensureCreated;
67
+ /** Refresh the elapsed-time footer periodically even with no phase change. */
68
+ private startTicker;
69
+ private safeEdit;
70
+ private stopTimers;
71
+ /** Dedupe key; includes elapsed so the ticking footer still advances. */
72
+ private signature;
73
+ private formatElapsed;
74
+ buildView(): CompactProgressView;
75
+ }
@@ -0,0 +1,177 @@
1
+ // A tiny live-status controller dedicated to the /compact (and auto-compact)
2
+ // flow, so the user gets a visible "正在压缩…" card that ticks while a large
3
+ // session is being compacted — instead of staring at nothing for tens of
4
+ // seconds while native compaction grinds (and sometimes times out).
5
+ //
6
+ // It deliberately does NOT reuse LiveStatusController: that one models a tool
7
+ // run (activity window, tool-call count, model footer). Compaction has just a
8
+ // few coarse phases and a single elapsed timer, so a focused, self-contained
9
+ // controller keeps the card clean and the logic trivial to test.
10
+ const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_STATUS_DELAY_MS || 500);
11
+ const DEFAULT_TICK_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_STATUS_TICK_MS || 1000);
12
+ /**
13
+ * Lifecycle: start() → (optional) toToolTrim() when native gives up → one of
14
+ * done()/noop()/fail(). The card is created lazily after `delayMs` so a fast
15
+ * compaction that finishes first never shows a card at all. All terminal calls
16
+ * are idempotent and safe even if the card was never created.
17
+ */
18
+ export class CompactProgressController {
19
+ callbacks;
20
+ opts;
21
+ messageId;
22
+ startedAt = Date.now();
23
+ phase = "native";
24
+ state = "running";
25
+ detail;
26
+ createTimer;
27
+ tickTimer;
28
+ createPromise;
29
+ finalized = false;
30
+ disabled = false;
31
+ lastSignature = "";
32
+ constructor(callbacks, opts = {}) {
33
+ this.callbacks = callbacks;
34
+ this.opts = opts;
35
+ }
36
+ get id() { return this.messageId; }
37
+ /** Begin the flow; schedules lazy card creation after delayMs. */
38
+ start() {
39
+ if (this.disabled || this.finalized || this.createTimer || this.messageId)
40
+ return;
41
+ this.startedAt = Date.now();
42
+ this.createTimer = setTimeout(() => {
43
+ this.createTimer = undefined;
44
+ this.createPromise = this.ensureCreated();
45
+ }, this.opts.delayMs ?? DEFAULT_DELAY_MS);
46
+ this.createTimer.unref?.();
47
+ }
48
+ /** Native compaction gave up (timeout/no-op); switch the card to fast-trim. */
49
+ async toToolTrim() {
50
+ if (this.finalized || this.phase === "tool-trim")
51
+ return;
52
+ this.phase = "tool-trim";
53
+ await this.refresh();
54
+ }
55
+ /** Finished successfully; patch the card to the done state with a detail line. */
56
+ async done(detail) {
57
+ await this.finish("done", detail);
58
+ }
59
+ /** Nothing to compact; patch the card to a neutral "no-op" state. */
60
+ async noop(detail) {
61
+ await this.finish("noop", detail);
62
+ }
63
+ /** Failed; patch the card to the failed state with an error detail line. */
64
+ async fail(detail) {
65
+ await this.finish("failed", detail);
66
+ }
67
+ /** Tear down timers without sending anything (e.g. on unexpected disposal). */
68
+ dispose() {
69
+ this.finalized = true;
70
+ this.stopTimers();
71
+ }
72
+ async finish(state, detail) {
73
+ if (this.finalized)
74
+ return;
75
+ this.state = state;
76
+ this.detail = detail;
77
+ this.finalized = true;
78
+ this.stopTimers();
79
+ // Wait for any in-flight lazy create so we can patch (not orphan) the card.
80
+ if (this.createPromise)
81
+ await this.createPromise.catch(() => { });
82
+ if (!this.messageId || this.disabled)
83
+ return;
84
+ await this.safeEdit(true);
85
+ }
86
+ async refresh() {
87
+ if (this.finalized || this.disabled)
88
+ return;
89
+ await this.ensureCreatedNow();
90
+ await this.safeEdit();
91
+ }
92
+ async ensureCreatedNow() {
93
+ if (this.createTimer) {
94
+ clearTimeout(this.createTimer);
95
+ this.createTimer = undefined;
96
+ this.createPromise = this.ensureCreated();
97
+ }
98
+ if (this.createPromise)
99
+ await this.createPromise.catch(() => { });
100
+ }
101
+ async ensureCreated() {
102
+ if (this.disabled || this.finalized || this.messageId)
103
+ return;
104
+ try {
105
+ const id = await this.callbacks.create(this.buildView());
106
+ if (!id) {
107
+ this.disabled = true;
108
+ return;
109
+ }
110
+ this.messageId = id;
111
+ this.lastSignature = this.signature();
112
+ this.startTicker();
113
+ }
114
+ catch (err) {
115
+ this.disabled = true;
116
+ this.callbacks.warn?.("compact progress create failed", err);
117
+ }
118
+ }
119
+ /** Refresh the elapsed-time footer periodically even with no phase change. */
120
+ startTicker() {
121
+ if (this.tickTimer || this.disabled || this.finalized)
122
+ return;
123
+ const tickMs = this.opts.tickMs ?? DEFAULT_TICK_MS;
124
+ this.tickTimer = setInterval(() => {
125
+ if (this.finalized || this.disabled || !this.messageId)
126
+ return;
127
+ void this.safeEdit();
128
+ }, tickMs);
129
+ this.tickTimer.unref?.();
130
+ }
131
+ async safeEdit(force = false) {
132
+ if (!this.messageId || this.disabled)
133
+ return;
134
+ if (!force && this.finalized)
135
+ return;
136
+ const sig = this.signature();
137
+ if (!force && sig === this.lastSignature)
138
+ return;
139
+ try {
140
+ await this.callbacks.edit(this.messageId, this.buildView());
141
+ this.lastSignature = sig;
142
+ }
143
+ catch (err) {
144
+ this.disabled = true;
145
+ this.stopTimers();
146
+ this.callbacks.warn?.("compact progress edit failed", err);
147
+ }
148
+ }
149
+ stopTimers() {
150
+ if (this.createTimer) {
151
+ clearTimeout(this.createTimer);
152
+ this.createTimer = undefined;
153
+ }
154
+ if (this.tickTimer) {
155
+ clearInterval(this.tickTimer);
156
+ this.tickTimer = undefined;
157
+ }
158
+ }
159
+ /** Dedupe key; includes elapsed so the ticking footer still advances. */
160
+ signature() {
161
+ return `${this.state}|${this.phase}|${this.formatElapsed()}|${this.detail || ""}`;
162
+ }
163
+ formatElapsed() {
164
+ const totalSec = Math.max(0, Math.floor((Date.now() - this.startedAt) / 1000));
165
+ const m = Math.floor(totalSec / 60);
166
+ const s = totalSec % 60;
167
+ return `${m}:${String(s).padStart(2, "0")}`;
168
+ }
169
+ buildView() {
170
+ return {
171
+ state: this.state,
172
+ phase: this.phase,
173
+ elapsed: this.formatElapsed(),
174
+ detail: this.detail,
175
+ };
176
+ }
177
+ }
@@ -135,6 +135,18 @@ export declare class FeishuBot {
135
135
  * provides one for normal-looking-but-truncated finals).
136
136
  */
137
137
  private looksTruncated;
138
+ /**
139
+ * Did the model type a tool call as plain text instead of emitting a real
140
+ * function call? When that happens the visible reply ends with a dangling
141
+ * invoke / parameter / function_calls tag (with or without the `antml:`
142
+ * prefix), because the tool-call markup leaked into the text channel and the
143
+ * tool never actually ran. We detect such a tag anchored at the END of the
144
+ * message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
145
+ * 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
146
+ * sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
147
+ * tool call correctly rather than the generic done-check.
148
+ */
149
+ private looksLikeBrokenToolCall;
138
150
  /** Current context usage percent for a session (totalTokens / contextTokens),
139
151
  * or 0 if it cannot be determined. Same basis as /status. */
140
152
  private getContextUsagePercent;
@@ -200,12 +212,48 @@ export declare class FeishuBot {
200
212
  private sendLiveStatusCard;
201
213
  private replyLiveStatusCard;
202
214
  private patchLiveStatusCard;
215
+ /**
216
+ * Render the /compact progress card. While running it shows a phase line plus a
217
+ * ticking elapsed footer so the user can see compaction is in progress; when
218
+ * terminal it collapses to a single compact line (success/skip/fail).
219
+ */
220
+ private buildCompactProgressCard;
221
+ private replyCompactCard;
222
+ private patchCompactCard;
203
223
  private sendTextMessage;
204
224
  private replyTextMessage;
205
225
  private editTextMessage;
206
226
  private deleteMessageById;
207
227
  private replyMessage;
208
228
  private extractBridgeAttachments;
229
+ /**
230
+ * Locate one bridge-attachment marker in `text` and recover its JSON payload,
231
+ * tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
232
+ * opening tag at all). Strategy:
233
+ * 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
234
+ * suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
235
+ * `</parameter>` the marker sometimes degrades into.
236
+ * 2. From just before that closer, brace-match BACKWARDS to the start of the
237
+ * JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
238
+ * 3. Return the span to strip [start,end) plus the extracted JSON string.
239
+ * `start` extends left to swallow any leftover opening-marker remnant (so no
240
+ * `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
241
+ */
242
+ private findBridgeAttachmentMarker;
243
+ /**
244
+ * Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
245
+ * at the END of a string. Built from every suffix of the full marker so that
246
+ * however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
247
+ * `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
248
+ * text. Trailing `>` and surrounding whitespace are also consumed.
249
+ */
250
+ private openMarkerRemnant;
251
+ /**
252
+ * Given the index of a closing tag, walk backwards to extract the balanced
253
+ * JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
254
+ * string and its start offset, or null if no balanced value is found.
255
+ */
256
+ private extractJsonBeforeIndex;
209
257
  private pushBridgeAttachment;
210
258
  private validateBridgeAttachmentPath;
211
259
  private inferFeishuFileType;
@@ -3,6 +3,7 @@ import { createRequire } from "module";
3
3
  import { persistBotModel } from "./config.js";
4
4
  import { getI18n, normalizeLocale } from "./i18n.js";
5
5
  import { LiveStatusController } from "./live-status.js";
6
+ import { CompactProgressController } from "./compact-progress.js";
6
7
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
7
8
  import { basename, extname, join, resolve } from "path";
8
9
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
@@ -28,6 +29,14 @@ function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AU
28
29
  // for the locale-appropriate phrase; detection accepts BOTH so either works.
29
30
  const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
30
31
  const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
32
+ // When the previous reply ended with a dangling tool-call tag (the model typed
33
+ // an invoke/tool call as plain text and it never ran), the auto-retry probe
34
+ // explicitly tells the agent its invoke format was broken and to retry, instead
35
+ // of the generic done-check. It still accepts the done phrase if actually done.
36
+ const AUTO_RETRY_BROKEN_TOOL_PROBE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_BROKEN_TOOL_PROBE
37
+ || `刚才的任务是不是没结束,invoke 格式错了,invoke 格式错了,请重试。如果结束了,回复“${AUTO_RETRY_DONE_PHRASE}”。`;
38
+ const AUTO_RETRY_BROKEN_TOOL_PROBE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_BROKEN_TOOL_PROBE_EN
39
+ || `Your last task did not finish — the invoke format was broken, the invoke format was broken, please retry. If it is actually done, reply "${AUTO_RETRY_DONE_PHRASE_EN}".`;
31
40
  // When an auto-retry probe errors AND context usage is at/above this percent,
32
41
  // auto-compact the session once and keep retrying (instead of giving up). This
33
42
  // rescues the “session too heavy → run times out / lock contention” failure mode.
@@ -1709,6 +1718,23 @@ export class FeishuBot {
1709
1718
  || /[}\]]\s*$/.test(t);
1710
1719
  return !endOk;
1711
1720
  }
1721
+ /**
1722
+ * Did the model type a tool call as plain text instead of emitting a real
1723
+ * function call? When that happens the visible reply ends with a dangling
1724
+ * invoke / parameter / function_calls tag (with or without the `antml:`
1725
+ * prefix), because the tool-call markup leaked into the text channel and the
1726
+ * tool never actually ran. We detect such a tag anchored at the END of the
1727
+ * message — that dangling tag is the tell-tale Stephen flagged ("如果监测到
1728
+ * 这个结尾,说明 tool call 格式出了问题"). On detection the auto-retry loop
1729
+ * sends an explicit "invoke 格式错了,请重试" probe so the agent re-issues the
1730
+ * tool call correctly rather than the generic done-check.
1731
+ */
1732
+ looksLikeBrokenToolCall(text) {
1733
+ const t = (text || "").trim();
1734
+ if (!t)
1735
+ return false;
1736
+ return /<\/?\s*(?:antml:)?(?:invoke|parameter|function_calls)\b[^>]*>\s*$/i.test(t);
1737
+ }
1712
1738
  /** Current context usage percent for a session (totalTokens / contextTokens),
1713
1739
  * or 0 if it cannot be determined. Same basis as /status. */
1714
1740
  async getContextUsagePercent(sessionKey) {
@@ -1807,7 +1833,12 @@ export class FeishuBot {
1807
1833
  const maxRetry = autoRetryMax();
1808
1834
  let compacted = false; // auto-compact at most once per auto-retry round
1809
1835
  let probedAtLeastOnce = false;
1836
+ // Set per-iteration when the reply under inspection ended with a dangling
1837
+ // tool-call tag (model typed an invoke as plain text); selects the explicit
1838
+ // "invoke format was broken, retry" probe instead of the generic done-check.
1839
+ let brokenToolCall = false;
1810
1840
  for (let attempt = 1; attempt <= maxRetry; attempt++) {
1841
+ brokenToolCall = false;
1811
1842
  if (!forceProbe) {
1812
1843
  const parsed = this.extractBridgeAttachments(reply);
1813
1844
  const trimmed = parsed.text.trim();
@@ -1820,20 +1851,32 @@ export class FeishuBot {
1820
1851
  // and runtime failures have their own handling upstream.
1821
1852
  if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1822
1853
  return reply;
1823
- if (!this.looksTruncated(trimmed))
1854
+ // A dangling invoke/tool-call tag is a strong "tool call leaked as text"
1855
+ // signal — engage even if the generic truncation heuristic would miss it.
1856
+ brokenToolCall = this.looksLikeBrokenToolCall(trimmed);
1857
+ if (!this.looksTruncated(trimmed) && !brokenToolCall)
1824
1858
  return reply;
1825
1859
  }
1826
1860
  forceProbe = false; // consumed; subsequent rounds inspect the probe reply
1827
1861
  const en = this.isEn(chatId);
1828
1862
  await liveStatus?.progress(en
1829
- ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
1830
- : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
1863
+ ? (brokenToolCall
1864
+ ? `⚠️ Tool-call format looked broken — asking it to retry (${attempt}/${maxRetry})…`
1865
+ : `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`)
1866
+ : (brokenToolCall
1867
+ ? `⚠️ 工具调用格式疑似出错,正在让它重试(${attempt}/${maxRetry})…`
1868
+ : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`)).catch(() => { });
1831
1869
  // One short line, in the user's own voice (we intentionally do not change
1832
1870
  // senderName). Each locale uses its own done-phrase so the English probe
1833
- // stays fully English; detection accepts both phrases.
1834
- const probe = en
1835
- ? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
1836
- : `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`;
1871
+ // stays fully English; detection accepts both phrases. When the previous
1872
+ // reply ended with a dangling tool-call tag, send the explicit "invoke
1873
+ // format was broken, retry" probe instead of the generic done-check so the
1874
+ // agent re-issues the tool call correctly.
1875
+ const probe = brokenToolCall
1876
+ ? (en ? AUTO_RETRY_BROKEN_TOOL_PROBE_EN : AUTO_RETRY_BROKEN_TOOL_PROBE)
1877
+ : (en
1878
+ ? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
1879
+ : `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`);
1837
1880
  let probeReply;
1838
1881
  try {
1839
1882
  probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
@@ -2339,6 +2382,73 @@ export class FeishuBot {
2339
2382
  data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
2340
2383
  });
2341
2384
  }
2385
+ /**
2386
+ * Render the /compact progress card. While running it shows a phase line plus a
2387
+ * ticking elapsed footer so the user can see compaction is in progress; when
2388
+ * terminal it collapses to a single compact line (success/skip/fail).
2389
+ */
2390
+ buildCompactProgressCard(view, chatId) {
2391
+ const en = this.isEn(chatId);
2392
+ if (view.state !== "running") {
2393
+ let line;
2394
+ if (view.state === "done") {
2395
+ line = en
2396
+ ? `✅ Session compacted${view.detail ? ` (${view.detail})` : ""} · ⏱ ${view.elapsed}`
2397
+ : `✅ Session 已压缩${view.detail ? `(${view.detail})` : ""} · ⏱ 耗时 ${view.elapsed}`;
2398
+ }
2399
+ else if (view.state === "noop") {
2400
+ line = en
2401
+ ? `ℹ️ Session not compacted${view.detail ? ` (${view.detail})` : ""}`
2402
+ : `ℹ️ Session 未压缩${view.detail ? `(${view.detail})` : ""}`;
2403
+ }
2404
+ else {
2405
+ line = en
2406
+ ? `❌ Compact failed${view.detail ? `: ${view.detail}` : ""}`
2407
+ : `❌ 压缩失败${view.detail ? `:${view.detail}` : ""}`;
2408
+ }
2409
+ return {
2410
+ schema: "2.0",
2411
+ config: { update_multi: true, width_mode: "fill" },
2412
+ body: { elements: [{ tag: "markdown", content: `<font color='grey'>${this.escapeCardText(line)}</font>` }] },
2413
+ };
2414
+ }
2415
+ // Running: phase line + ticking elapsed footer.
2416
+ const phaseLine = view.phase === "tool-trim"
2417
+ ? (en ? "🧹 Native compaction is slow — switching to fast trim…" : "🧹 原生压缩较慢,已切换快速压缩…")
2418
+ : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
2419
+ const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2420
+ return {
2421
+ schema: "2.0",
2422
+ config: { update_multi: true, width_mode: "fill" },
2423
+ header: {
2424
+ title: { tag: "plain_text", content: en ? "Compacting" : "正在压缩" },
2425
+ template: "blue",
2426
+ },
2427
+ body: {
2428
+ elements: [
2429
+ { tag: "markdown", content: this.escapeCardText(phaseLine) },
2430
+ { tag: "hr" },
2431
+ { tag: "markdown", content: `<font color='grey'>${footer}</font>` },
2432
+ ],
2433
+ },
2434
+ };
2435
+ }
2436
+ async replyCompactCard(messageId, view, chatId) {
2437
+ const res = await this.client.im.message.reply({
2438
+ path: { message_id: messageId },
2439
+ data: {
2440
+ content: JSON.stringify(this.buildCompactProgressCard(view, chatId)),
2441
+ msg_type: "interactive",
2442
+ },
2443
+ });
2444
+ return res?.data?.message_id || res?.message_id;
2445
+ }
2446
+ async patchCompactCard(messageId, view, chatId) {
2447
+ await this.client.im.message.patch({
2448
+ path: { message_id: messageId },
2449
+ data: { content: JSON.stringify(this.buildCompactProgressCard(view, chatId)) },
2450
+ });
2451
+ }
2342
2452
  async sendTextMessage(chatId, text) {
2343
2453
  try {
2344
2454
  const res = await this.client.im.message.create({
@@ -2407,24 +2517,47 @@ export class FeishuBot {
2407
2517
  }
2408
2518
  extractBridgeAttachments(reply) {
2409
2519
  const attachments = [];
2410
- // Some models occasionally corrupt the attachment marker while preserving
2411
- // the exact JSON payload: the closing tag can become </parameter>, or the
2412
- // opening "<LMA" prefix can be truncated leaving "_BRIDGE_ATTACHMENTS>".
2413
- // Treat these as recoverable shapes; otherwise the marker leaks as
2414
- // user-visible text and attachments are not sent.
2415
- const markerPattern = /(?:<LMA_BRIDGE_ATTACHMENTS>|_BRIDGE_ATTACHMENTS>)([\s\S]*?)(?:<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>)/g;
2416
- let text = reply.replace(markerPattern, (_match, jsonText) => {
2520
+ // Models occasionally corrupt the attachment marker. The opening tag is the
2521
+ // least reliable part: we have seen it lose the "<LMA" prefix ("_BRIDGE_…"),
2522
+ // lose even more ("RIDGE_ATTACHMENTS>"), or drop entirely. The closing tag is
2523
+ // far more reliably emitted. So instead of enumerating opening-tag shapes,
2524
+ // anchor on the closer and recover the JSON payload by brace-balancing
2525
+ // backwards from it — robust no matter how many leading chars are dropped.
2526
+ let text = reply;
2527
+ let guard = 0;
2528
+ for (;;) {
2529
+ if (guard++ > 50)
2530
+ break; // safety: never loop unbounded on pathological input
2531
+ const found = this.findBridgeAttachmentMarker(text);
2532
+ if (!found)
2533
+ break;
2534
+ const { start, end, json } = found;
2535
+ let consumed = false;
2417
2536
  try {
2418
- const parsed = JSON.parse(String(jsonText).trim());
2419
- const rawAttachments = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
2537
+ const parsed = JSON.parse(json);
2538
+ const rawAttachments = Array.isArray(parsed)
2539
+ ? parsed
2540
+ : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
2541
+ const before = attachments.length;
2420
2542
  for (const item of rawAttachments)
2421
2543
  this.pushBridgeAttachment(attachments, item);
2544
+ // Only strip the span if it actually yielded at least one attachment;
2545
+ // otherwise leave the text alone rather than eat unrelated content.
2546
+ consumed = attachments.length > before;
2422
2547
  }
2423
2548
  catch (err) {
2424
2549
  console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
2425
2550
  }
2426
- return "";
2427
- });
2551
+ if (consumed) {
2552
+ text = (text.slice(0, start) + text.slice(end)).replace(/[^\S\n]+\n/g, "\n");
2553
+ }
2554
+ else {
2555
+ // Could not use this span; blank it out so we do not re-find it forever,
2556
+ // but keep scanning in case another valid marker exists earlier.
2557
+ text = text.slice(0, start) + text.slice(end);
2558
+ }
2559
+ }
2560
+ text = text.trim();
2428
2561
  // Compatibility fallback for agents that use OpenClaw's channel directive
2429
2562
  // syntax instead of the LMA marker. In Feishu/LMA, leaving MEDIA:<path> in
2430
2563
  // plain text only shows the path, so parse it into bridge attachments.
@@ -2439,6 +2572,111 @@ export class FeishuBot {
2439
2572
  }).trim();
2440
2573
  return { text, attachments };
2441
2574
  }
2575
+ /**
2576
+ * Locate one bridge-attachment marker in `text` and recover its JSON payload,
2577
+ * tolerating arbitrary corruption of the OPENING tag (lost prefix, or no
2578
+ * opening tag at all). Strategy:
2579
+ * 1. Find a closing tag — the full `</LMA_BRIDGE_ATTACHMENTS>`, or a partial
2580
+ * suffix of it (e.g. `_BRIDGE_ATTACHMENTS>`, `RIDGE_ATTACHMENTS>`), or the
2581
+ * `</parameter>` the marker sometimes degrades into.
2582
+ * 2. From just before that closer, brace-match BACKWARDS to the start of the
2583
+ * JSON value (`{...}` or `[...]`), so the opening marker is irrelevant.
2584
+ * 3. Return the span to strip [start,end) plus the extracted JSON string.
2585
+ * `start` extends left to swallow any leftover opening-marker remnant (so no
2586
+ * `RIDGE_ATTACHMENTS>` text leaks into the visible reply).
2587
+ */
2588
+ findBridgeAttachmentMarker(text) {
2589
+ // Closing-tag candidates, longest/most-specific first. We accept partial
2590
+ // suffixes of the real closing tag because the opening corruption pattern
2591
+ // (dropped leading chars) also happens to the closer.
2592
+ const closeRe = /<\/LMA_BRIDGE_ATTACHMENTS>|[A-Z_]*BRIDGE_ATTACHMENTS>\s*<\/LMA_BRIDGE_ATTACHMENTS>|<\/parameter>/g;
2593
+ // The JSON sits between an (often corrupted) opener and the closer. Find the
2594
+ // closer first, then walk back to the JSON. Prefer the canonical closer.
2595
+ const closers = [];
2596
+ const canonical = /<\/LMA_BRIDGE_ATTACHMENTS>/g;
2597
+ let m;
2598
+ while ((m = canonical.exec(text)))
2599
+ closers.push({ idx: m.index, len: m[0].length });
2600
+ if (closers.length === 0) {
2601
+ // Degraded closer: `</parameter>` immediately after the JSON payload.
2602
+ const param = /<\/parameter>/g;
2603
+ while ((m = param.exec(text)))
2604
+ closers.push({ idx: m.index, len: m[0].length });
2605
+ }
2606
+ void closeRe; // (kept for documentation of accepted shapes)
2607
+ for (const c of closers) {
2608
+ const json = this.extractJsonBeforeIndex(text, c.idx);
2609
+ if (!json)
2610
+ continue;
2611
+ // Extend `start` left over any opening-marker remnant + surrounding space,
2612
+ // so the visible text does not keep a dangling `RIDGE_ATTACHMENTS>` etc.
2613
+ // The remnant is always some trailing SUFFIX of `<LMA_BRIDGE_ATTACHMENTS>`
2614
+ // (corruption drops leading chars), so match any such suffix.
2615
+ let start = json.start;
2616
+ const lead = text.slice(Math.max(0, start - 48), start);
2617
+ const leadMarker = this.openMarkerRemnant().exec(lead);
2618
+ if (leadMarker)
2619
+ start = Math.max(0, start - (lead.length - leadMarker.index));
2620
+ return { start, end: c.idx + c.len, json: json.text };
2621
+ }
2622
+ return null;
2623
+ }
2624
+ /**
2625
+ * Regex matching a trailing remnant of the opening marker `<LMA_BRIDGE_ATTACHMENTS>`
2626
+ * at the END of a string. Built from every suffix of the full marker so that
2627
+ * however many leading characters the model dropped (`_BRIDGE_ATTACHMENTS>`,
2628
+ * `RIDGE_ATTACHMENTS>`, `>` …), the leftover is fully stripped from the visible
2629
+ * text. Trailing `>` and surrounding whitespace are also consumed.
2630
+ */
2631
+ openMarkerRemnant() {
2632
+ const full = "<LMA_BRIDGE_ATTACHMENTS>";
2633
+ const suffixes = [];
2634
+ for (let i = 0; i < full.length; i++)
2635
+ suffixes.push(full.slice(i).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
2636
+ // Longest suffixes first so the regex prefers the most complete remnant.
2637
+ return new RegExp(`(?:${suffixes.join("|")})\\s*$`);
2638
+ }
2639
+ /**
2640
+ * Given the index of a closing tag, walk backwards to extract the balanced
2641
+ * JSON value (`{...}` or `[...]`) that ends just before it. Returns the JSON
2642
+ * string and its start offset, or null if no balanced value is found.
2643
+ */
2644
+ extractJsonBeforeIndex(text, closeIdx) {
2645
+ // Skip whitespace immediately before the closer.
2646
+ let end = closeIdx;
2647
+ while (end > 0 && /\s/.test(text[end - 1]))
2648
+ end--;
2649
+ if (end === 0)
2650
+ return null;
2651
+ const lastChar = text[end - 1];
2652
+ const open = lastChar === "}" ? "{" : lastChar === "]" ? "[" : null;
2653
+ if (!open)
2654
+ return null;
2655
+ const close = lastChar;
2656
+ // Brace-balance backwards, ignoring braces inside strings.
2657
+ let depth = 0;
2658
+ let inStr = false;
2659
+ for (let i = end - 1; i >= 0; i--) {
2660
+ const ch = text[i];
2661
+ if (inStr) {
2662
+ if (ch === '"' && text[i - 1] !== "\\")
2663
+ inStr = false;
2664
+ continue;
2665
+ }
2666
+ if (ch === '"') {
2667
+ inStr = true;
2668
+ continue;
2669
+ }
2670
+ if (ch === close)
2671
+ depth++;
2672
+ else if (ch === open) {
2673
+ depth--;
2674
+ if (depth === 0)
2675
+ return { text: text.slice(i, end), start: i };
2676
+ }
2677
+ }
2678
+ return null;
2679
+ }
2442
2680
  pushBridgeAttachment(attachments, item) {
2443
2681
  if (!item || typeof item.path !== "string")
2444
2682
  return;
@@ -2914,7 +3152,7 @@ export class FeishuBot {
2914
3152
  * into the model). A backup is always written before any file change.
2915
3153
  * Returns a structured result describing what happened.
2916
3154
  */
2917
- async compactWithFallback(sessionKey) {
3155
+ async compactWithFallback(sessionKey, onPhase) {
2918
3156
  // 1. Native compaction first.
2919
3157
  let nativeReason;
2920
3158
  try {
@@ -2926,7 +3164,10 @@ export class FeishuBot {
2926
3164
  catch (err) {
2927
3165
  nativeReason = err.message;
2928
3166
  }
2929
- // 2. Fall back to tool-trim on the transcript file.
3167
+ // 2. Fall back to tool-trim on the transcript file. Tell the caller so a
3168
+ // progress card can flip from "compacting" to "fast-trim" (this is the
3169
+ // slow→still-working transition the user most needs to see).
3170
+ onPhase?.("tool-trim");
2930
3171
  let sessionId;
2931
3172
  try {
2932
3173
  const info = await this.openclawClient.getSessionInfo(sessionKey);
@@ -2963,29 +3204,53 @@ export class FeishuBot {
2963
3204
  async handleCompactCommand(chatId, messageId) {
2964
3205
  const sessionKey = this.getSessionKey(chatId);
2965
3206
  const en = this.isEn(chatId);
3207
+ // Large sessions can take tens of seconds to compact (native compaction may
3208
+ // even time out before the tool-trim fallback kicks in). Show a ticking
3209
+ // "compacting…" card so the user can SEE it is working instead of staring at
3210
+ // nothing and assuming nothing is happening. The card is created lazily, so
3211
+ // a fast compaction that finishes quickly never flashes a card at all.
3212
+ const progress = new CompactProgressController({
3213
+ create: (view) => this.sendOrdered(chatId, () => this.replyCompactCard(messageId, view, chatId)),
3214
+ edit: (id, view) => this.sendOrdered(chatId, () => this.patchCompactCard(id, view, chatId)),
3215
+ warn: (msg, err) => console.warn(`[${this.config.name}] ${msg}`, err instanceof Error ? err.message : err),
3216
+ }, { locale: en ? "en" : "zh" });
3217
+ progress.start();
2966
3218
  try {
2967
- const r = await this.compactWithFallback(sessionKey);
3219
+ const r = await this.compactWithFallback(sessionKey, (phase) => {
3220
+ if (phase === "tool-trim")
3221
+ void progress.toToolTrim().catch(() => { });
3222
+ });
2968
3223
  if (r.compacted) {
2969
- if (r.method === "tool-trim") {
2970
- await this.replyMessage(messageId, en
2971
- ? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})`
2972
- : `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`);
2973
- }
2974
- else {
2975
- await this.replyMessage(messageId, en ? `✅ Session compacted` : `✅ Session 已压缩`);
3224
+ // If a card was shown, patch it in place; otherwise (fast finish, no card)
3225
+ // fall back to a normal reply so the user still gets confirmation.
3226
+ const detail = r.method === "tool-trim" && r.detail
3227
+ ? (en ? `tool-trim ${r.detail}` : `删工具调用 ${r.detail}`)
3228
+ : (en ? "native" : "原生压缩");
3229
+ await progress.done(detail);
3230
+ if (!progress.id) {
3231
+ await this.replyMessage(messageId, r.method === "tool-trim"
3232
+ ? (en ? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})` : `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`)
3233
+ : (en ? `✅ Session compacted` : `✅ Session 已压缩`));
2976
3234
  }
2977
3235
  }
2978
3236
  else {
2979
3237
  const reason = r.reason || (en ? "nothing to compact" : "无需压缩");
2980
- await this.replyMessage(messageId, en
2981
- ? `ℹ️ Session not compacted (${reason})`
2982
- : `ℹ️ Session 未压缩(${reason})`);
3238
+ await progress.noop(reason);
3239
+ if (!progress.id) {
3240
+ await this.replyMessage(messageId, en
3241
+ ? `ℹ️ Session not compacted (${reason})`
3242
+ : `ℹ️ Session 未压缩(${reason})`);
3243
+ }
2983
3244
  }
2984
3245
  }
2985
3246
  catch (err) {
2986
- await this.replyMessage(messageId, en
2987
- ? `❌ Compact failed: ${err.message}`
2988
- : `❌ 压缩失败: ${err.message}`);
3247
+ const reason = err.message;
3248
+ await progress.fail(reason);
3249
+ if (!progress.id) {
3250
+ await this.replyMessage(messageId, en
3251
+ ? `❌ Compact failed: ${reason}`
3252
+ : `❌ 压缩失败: ${reason}`);
3253
+ }
2989
3254
  }
2990
3255
  }
2991
3256
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {