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.
- package/dist/compact-progress.d.ts +75 -0
- package/dist/compact-progress.js +177 -0
- package/dist/feishu-bot.d.ts +48 -0
- package/dist/feishu-bot.js +299 -34
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/feishu-bot.d.ts
CHANGED
|
@@ -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;
|
package/dist/feishu-bot.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
?
|
|
1830
|
-
|
|
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
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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
|
-
//
|
|
2411
|
-
//
|
|
2412
|
-
//
|
|
2413
|
-
//
|
|
2414
|
-
//
|
|
2415
|
-
|
|
2416
|
-
let text = reply
|
|
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(
|
|
2419
|
-
const rawAttachments = Array.isArray(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
|
-
|
|
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
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
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
|
|
2981
|
-
|
|
2982
|
-
|
|
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
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
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
|
/**
|