openclaw-lark-multi-agent 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,6 +37,7 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
37
37
  - Feishu image download and OpenClaw multimodal attachment forwarding
38
38
  - Bridge attachment marker protocol for generated files/images/documents
39
39
  - Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
40
+ - Live status card: in non-verbose mode each run shows a single self-updating Feishu interactive card (title + recent activity window with per-line timestamps + elapsed/model footer, refreshed once per second), which collapses to a compact one-line summary on a clean finish and retains the recent activity on failure for debugging
40
41
  - Bridge-level slash commands and escaped OpenClaw slash commands
41
42
  - `/discuss` mode for barrier-style multi-bot group discussion, including per-round markers and no-reply status notices
42
43
  - `/chairman` role: a single per-group chairman that answers plain messages when no bot is in Free mode, and acts as host, challenger, and summarizer inside `/discuss`
@@ -231,10 +232,13 @@ Bridge-level commands use a single slash and are handled by this project:
231
232
  - `/status` — show bot model, token usage, and session state
232
233
  - `/compact` — compact the OpenClaw session
233
234
  - `/reset` — reset the OpenClaw session
235
+ - `/stop` — force-stop a stuck run for this bot in this chat and unlock the queue
234
236
  - `/verbose` — toggle tool-call messages for this bot in this chat
237
+ - `/livestatus [on|off]` — toggle, or explicitly enable/disable, the self-updating run-status card shown in non-verbose mode (default on)
235
238
  - `/free [on|off]` — toggle, or explicitly enable/disable, this bot's Free mode in the current group chat
236
239
  - `/mute` — toggle this bot's mute mode in the current group chat
237
240
  - `/mode` — show this bot's current mode in the current chat
241
+ - `/model [id]` — show or switch this bot's bound model (persisted)
238
242
  - `/discuss on|off|status|stop|rounds N` — control group-level multi-bot discussion mode (requires a chairman to enable; default 10 rounds)
239
243
  - `/chairman [@Bot|off]` — set, view, or clear the single chairman for this group
240
244
  - `/locale [zh|en]` — set or view this group's language
@@ -323,7 +327,7 @@ Persistent constraints (such as "do not call Feishu send tools directly" and the
323
327
  `/discuss` is an explicit group-level multi-agent discussion scheduler. It is separate from Free mode:
324
328
 
325
329
  - `/free` controls whether a single bot may answer plain human messages.
326
- - `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all Free-mode bots plus the chairman in barrier-style rounds.
330
+ - `/discuss on` requires a chairman to be set first (`/chairman @Bot`). It lets one coordinator take over plain human messages and run all non-muted bots plus the chairman in barrier-style rounds. Free mode is ignored inside discussion: every bot that is not muted participates regardless of its Free setting.
327
331
  - Targeted mentions still fall through to normal routing, so `@GPT hello` works even while discuss mode is enabled.
328
332
  - Each participant receives the same round prompt and does not see other participants' replies from the current round until the next round.
329
333
  - The chairman speaks last each round: it gives its own view, challenges weak points, mediates disagreements, and decides whether to continue or conclude.
@@ -396,6 +400,23 @@ The bridge subscribes to Feishu `im.message.recalled_v1` events. When a user rec
396
400
 
397
401
  Version 1 behavior intentionally does **not** abort an OpenClaw run that has already started processing the recalled message, and it does not recall bot replies that were already sent. The first goal is to make recall reliable for queued/not-yet-processed messages.
398
402
 
403
+ ## Session health and error recovery
404
+
405
+ The bridge distinguishes a failed *run* from a dead *session*:
406
+
407
+ - During an active run, a health monitor polls the OpenClaw session. Only true
408
+ session-death states (`killed` / `dead` / `crashed`, matched on word
409
+ boundaries) are treated as unhealthy; run-level outcomes such as `aborted`,
410
+ `error`, or `timeout` are not, because the session usually remains usable for
411
+ the next message. A short re-check guards against transient blips.
412
+ - When a session is genuinely unhealthy, the bridge warns the user but does not
413
+ force a `/reset`: the current message is still attempted, and only an in-run
414
+ confirmation of death stops the wait. Users decide whether to retry.
415
+ - Recoverable agent errors are not surfaced as failures. When OpenClaw emits a
416
+ recoverable lifecycle error (for example `Context overflow: prompt too large`)
417
+ it auto-compacts and keeps the same run alive; the bridge defers instead of
418
+ rejecting, so a later real final reply wins. Only a genuine stall falls back to
419
+ the deferred error via the idle timeout. Non-recoverable errors still fail fast.
399
420
 
400
421
  ## Markdown, tables, and attachments
401
422
 
@@ -408,6 +429,50 @@ Assistant replies are sent as Feishu CardKit v2 cards. Markdown is preprocessed
408
429
 
409
430
  For generated files/images/documents, agents should use the bridge attachment marker protocol instead of calling Feishu messaging tools directly. The bridge strips the marker from the visible reply, validates the file path under the configured attachment directory, uploads/sends the attachment, and records it in local context. Markdown documents can be converted into Feishu cloud documents through this path.
410
431
 
432
+ ## Live status
433
+
434
+ In non-verbose mode, each run shows a single self-updating Feishu interactive
435
+ card so users can see progress without the noise of per-tool-call messages.
436
+ Verbose mode and live status are mutually exclusive: when `/verbose` is on, the
437
+ bridge emits the existing per-tool messages instead of a live status card.
438
+
439
+ While running, the card shows:
440
+
441
+ - a title (`<bot> is working`);
442
+ - a rolling window of the most recent activity lines (tool start `▸`, tool end
443
+ `✓`, and intermediate assistant text `•`), each prefixed with the relative
444
+ time `mm:ss` since the run started;
445
+ - a footer with elapsed time and the bound model name.
446
+
447
+ The card is created lazily (a fast reply that finishes within the create delay
448
+ never spawns a card, to avoid flicker) and is updated with `im.message.patch`,
449
+ which has no 20-edit cap. A 1-second ticker advances the elapsed timer even when
450
+ no new activity arrives.
451
+
452
+ When the run ends:
453
+
454
+ - a clean finish (normal reply or `NO_REPLY`) collapses the card to a single
455
+ compact grey line: status emoji + total tool calls + total elapsed;
456
+ - a failure (provider error, killed/unhealthy session, delivery error, or idle
457
+ timeout) keeps the recent activity window plus the summary, with an orange
458
+ header, so the steps leading up to the failure stay visible for debugging.
459
+
460
+ The final answer is always delivered separately through the normal interactive-card
461
+ delivery path, so Markdown renders correctly; the live status card is a distinct
462
+ message and never replaces or blocks the final reply.
463
+
464
+ Live status is on by default and can be toggled per bot/chat with
465
+ `/livestatus [on|off]`. It can be disabled globally by setting
466
+ `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS=0`. Tunable defaults:
467
+
468
+ | Environment variable | Default | Meaning |
469
+ | --- | --- | --- |
470
+ | `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS` | `1` | Master switch; `0` disables live status for all bots |
471
+ | `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS` | `800` | Delay before creating the card, so fast replies do not flicker one |
472
+ | `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_TICK_MS` | `1000` | How often the elapsed-time footer auto-refreshes |
473
+ | `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_HISTORY` | `6` | Number of recent activity lines kept in the card |
474
+ | `OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS` | `120` | Per-line character cap before truncation |
475
+
411
476
  ## Data model
412
477
 
413
478
  SQLite state lives in the configured data directory. Important tables:
@@ -421,7 +486,7 @@ SQLite state lives in the configured data directory. Important tables:
421
486
  - `delivery_outbox` — durable user-visible delivery ledger with claim/dedupe state
422
487
  - `recalled_messages` — recalled user messages excluded from pending work and future context
423
488
  - `processed_events` — Feishu event de-duplication
424
- - `bot_chat_settings` — per-bot/per-chat settings such as verbose mode
489
+ - `bot_chat_settings` — per-bot/per-chat settings such as verbose mode, mode, and live-status toggle
425
490
 
426
491
  ## Development
427
492
 
@@ -34,6 +34,8 @@ export declare class FeishuBot {
34
34
  private delayedFailureTimers;
35
35
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
36
36
  private lastRealDeliveryAt;
37
+ /** Per-chat: whether we've already sent the high-context alert this cycle. */
38
+ private contextAlerted;
37
39
  /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
38
40
  private activeDeliveryTargets;
39
41
  private adminOpenId;
@@ -117,6 +119,51 @@ export declare class FeishuBot {
117
119
  private isBridgeControlReply;
118
120
  private isLegacyBridgeControlMessage;
119
121
  private isRuntimeFailureText;
122
+ /**
123
+ * Is this main-run error worth auto-retrying (by resuming the session via the
124
+ * probe loop)? Mainly transient failures — request/response timeouts and
125
+ * aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
126
+ * (auth/permission/quota/billing/model-not-supported), which would just fail
127
+ * again. The error text comes from collectReply's `Agent error: <msg>`.
128
+ */
129
+ private isRetryableAgentError;
130
+ /**
131
+ * Heuristic: does this reply look truncated / mid-task? Bias toward false
132
+ * positives (“宁可错杀不放过”) — a false positive only costs one invisible
133
+ * confirmation round, and the session self-check is the real arbiter. We gate
134
+ * only on cheap text shape here, never on stopReason (the upstream rarely
135
+ * provides one for normal-looking-but-truncated finals).
136
+ */
137
+ private looksTruncated;
138
+ /** Current context usage percent for a session (totalTokens / contextTokens),
139
+ * or 0 if it cannot be determined. Same basis as /status. */
140
+ private getContextUsagePercent;
141
+ /**
142
+ * After a reply is delivered, alert the user once if context usage crossed the
143
+ * threshold (default 80%), so they can compact before a session bloats into the
144
+ * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
145
+ * alerted we stay quiet until usage drops back below the threshold (e.g. after
146
+ * a compact), then re-arm.
147
+ */
148
+ private maybeAlertHighContext;
149
+ /** True when the agent's reply is exactly a completion phrase (zh or en,
150
+ * tolerant of trailing punctuation/whitespace and case). */
151
+ /**
152
+ * True when the agent's reply means “done”. We accept the exact phrase as well
153
+ * as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
154
+ * when a negation/not-finished cue is present (“还没结束” / “not done”), so a
155
+ * “still working” answer never counts as completion.
156
+ */
157
+ private isDonePhrase;
158
+ /**
159
+ * Auto-retry loop: if `reply` looks truncated, ask the same session whether it
160
+ * finished. The session is the arbiter: if it replies exactly the done phrase,
161
+ * the ORIGINAL reply was the real result and we deliver that. Otherwise the
162
+ * session kept working and its new output becomes the latest reply, which we
163
+ * re-check. Loops until the session confirms completion, a reply no longer
164
+ * looks truncated, the budget is spent, or the session cannot answer.
165
+ */
166
+ private autoRetryUntilComplete;
120
167
  private cancelDelayedFailure;
121
168
  private setActiveDeliveryTarget;
122
169
  private scheduleDelayedFailure;
@@ -202,6 +249,17 @@ export declare class FeishuBot {
202
249
  /**
203
250
  * Handle /compact command: compress session context.
204
251
  */
252
+ /**
253
+ * Compact a session with a fallback chain:
254
+ * 1. OpenClaw's native (LLM-summary) compaction — best semantics.
255
+ * 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
256
+ * the transcript file to drop tool calls/results while keeping the
257
+ * conversation. This needs no model, so it works no matter how large the
258
+ * session is (the native path itself fails to fit an oversized session
259
+ * into the model). A backup is always written before any file change.
260
+ * Returns a structured result describing what happened.
261
+ */
262
+ private compactWithFallback;
205
263
  private handleCompactCommand;
206
264
  /**
207
265
  * Handle /reset command: fire sessions.reset and confirm.
@@ -8,6 +8,7 @@ import { basename, extname, join, resolve } from "path";
8
8
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
9
9
  import { buildFeishuCardElements } from "./markdown.js";
10
10
  import { discussionManager } from "./discussion-manager.js";
11
+ import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
11
12
  const require = createRequire(import.meta.url);
12
13
  const LMA_VERSION = require("../package.json").version;
13
14
  const MAX_BOT_STREAK = 10;
@@ -17,6 +18,26 @@ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
17
18
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
18
19
  const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
19
20
  const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
21
+ // Auto-retry: when a reply looks truncated/incomplete, ask the same session
22
+ // whether it finished. If it did not, let it continue; loop until it confirms
23
+ // completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
24
+ // Read at call time (not module load) so tests can toggle it per-case.
25
+ function autoRetryEnabled() { return process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY !== "0"; }
26
+ function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_MAX || 5); }
27
+ // The exact phrase the agent must reply to confirm completion. The probe asks
28
+ // for the locale-appropriate phrase; detection accepts BOTH so either works.
29
+ const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
30
+ const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
31
+ // When an auto-retry probe errors AND context usage is at/above this percent,
32
+ // auto-compact the session once and keep retrying (instead of giving up). This
33
+ // rescues the “session too heavy → run times out / lock contention” failure mode.
34
+ function autoRetryCompactPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_COMPACT_PCT || 50); }
35
+ // How many of the most-recent tool calls (and their results) tool-trim keeps
36
+ // intact — recent tool calls may still be relevant to the agent's next step.
37
+ function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
38
+ // After a reply is delivered, if context usage reaches this percent, send the
39
+ // user a one-time alert to compact. 0 disables the alert.
40
+ function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
20
41
  class UnhealthySessionError extends Error {
21
42
  status;
22
43
  constructor(status) {
@@ -58,6 +79,8 @@ export class FeishuBot {
58
79
  delayedFailureTimers = new Map();
59
80
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
60
81
  lastRealDeliveryAt = new Map();
82
+ /** Per-chat: whether we've already sent the high-context alert this cycle. */
83
+ contextAlerted = new Map();
61
84
  /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
62
85
  activeDeliveryTargets = new Map();
63
86
  adminOpenId;
@@ -1220,6 +1243,31 @@ export class FeishuBot {
1220
1243
  onProgress: (event) => liveStatus?.progress(event),
1221
1244
  }));
1222
1245
  }
1246
+ catch (mainErr) {
1247
+ // The main run failed before returning a reply. If it is a retryable
1248
+ // error (e.g. a request timeout) and not a session-health failure,
1249
+ // reuse the SAME auto-retry mechanism to let the session resume from
1250
+ // where it was interrupted, instead of giving up immediately.
1251
+ if (mainErr instanceof UnhealthySessionError)
1252
+ throw mainErr;
1253
+ const errText = mainErr?.message || String(mainErr);
1254
+ if (autoRetryEnabled() && !isNativeCommandTrigger && this.isRetryableAgentError(errText)) {
1255
+ console.warn(`[${this.config.name}] main run failed (${errText.slice(0, 80)}) for ${chatId.slice(-8)}; entering auto-retry`);
1256
+ reply = await this.autoRetryUntilComplete({
1257
+ chatId,
1258
+ sessionKey,
1259
+ senderName: lastHuman.senderName,
1260
+ triggerId,
1261
+ lastHumanMessageId: lastHuman.messageId || "",
1262
+ isNativeCommandTrigger,
1263
+ initialError: errText,
1264
+ liveStatus,
1265
+ });
1266
+ }
1267
+ else {
1268
+ throw mainErr;
1269
+ }
1270
+ }
1223
1271
  finally {
1224
1272
  // OpenClaw may emit the final assistant session.message just after
1225
1273
  // collectReply returns. Keep the trigger mapping briefly so proactive
@@ -1227,6 +1275,19 @@ export class FeishuBot {
1227
1275
  releaseActiveDeliveryTarget();
1228
1276
  }
1229
1277
  console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
1278
+ // Auto-retry: if the reply looks truncated/incomplete, confirm with the
1279
+ // session and loop until it reports done (or the budget is spent). This
1280
+ // replaces `reply` with the final, confirmed result before delivery.
1281
+ reply = await this.autoRetryUntilComplete({
1282
+ chatId,
1283
+ sessionKey,
1284
+ senderName: lastHuman.senderName,
1285
+ triggerId,
1286
+ lastHumanMessageId: lastHuman.messageId || "",
1287
+ isNativeCommandTrigger,
1288
+ initialReply: reply,
1289
+ liveStatus,
1290
+ });
1230
1291
  const parsedReply = this.extractBridgeAttachments(reply);
1231
1292
  const visibleReply = parsedReply.text;
1232
1293
  const trimmedReply = visibleReply.trim();
@@ -1325,6 +1386,8 @@ export class FeishuBot {
1325
1386
  // not re-process any of them.
1326
1387
  for (const id of mergedTriggerIds)
1327
1388
  this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
1389
+ // After a successful reply, warn the user once if context is high.
1390
+ void this.maybeAlertHighContext(chatId);
1328
1391
  }
1329
1392
  catch (err) {
1330
1393
  // enqueueAndDispatchDelivery already sent a user-visible delivery
@@ -1603,6 +1666,247 @@ export class FeishuBot {
1603
1666
  isRuntimeFailureText(text) {
1604
1667
  return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
1605
1668
  }
1669
+ /**
1670
+ * Is this main-run error worth auto-retrying (by resuming the session via the
1671
+ * probe loop)? Mainly transient failures — request/response timeouts and
1672
+ * aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
1673
+ * (auth/permission/quota/billing/model-not-supported), which would just fail
1674
+ * again. The error text comes from collectReply's `Agent error: <msg>`.
1675
+ */
1676
+ isRetryableAgentError(errText) {
1677
+ const t = (errText || "").toLowerCase();
1678
+ // Terminal errors: never auto-retry.
1679
+ if (/(unauthorized|permission|forbidden|invalid api key|quota|billing|insufficient|model_not_supported|not supported|invalid_request)/.test(t))
1680
+ return false;
1681
+ // Retryable: timeouts and transient interruptions.
1682
+ return /(timed out|timeout|aborted|operation was aborted|no response|request failed|temporarily|connection|econnreset|socket hang|stream)/.test(t);
1683
+ }
1684
+ /**
1685
+ * Heuristic: does this reply look truncated / mid-task? Bias toward false
1686
+ * positives (“宁可错杀不放过”) — a false positive only costs one invisible
1687
+ * confirmation round, and the session self-check is the real arbiter. We gate
1688
+ * only on cheap text shape here, never on stopReason (the upstream rarely
1689
+ * provides one for normal-looking-but-truncated finals).
1690
+ */
1691
+ looksTruncated(text) {
1692
+ const t = (text || "").trim();
1693
+ if (!t)
1694
+ return false; // empty/NO_REPLY handled elsewhere
1695
+ // Unbalanced code fence => almost certainly cut off mid-block.
1696
+ const fences = (t.match(/```/g) || []).length;
1697
+ if (fences % 2 === 1)
1698
+ return true;
1699
+ // Ends with an obvious continuation cue (dangling connector / lead-in).
1700
+ if (/[,,::、;;]\s*$/u.test(t))
1701
+ return true;
1702
+ if (/(接下来|然后|首先|现在我|让我|下一步|let me|now i|next,?|first,?)\s*$/iu.test(t))
1703
+ return true;
1704
+ // Core signal: ends without any sentence-ending punctuation / closer
1705
+ // (“连句号都没有就感觉没成功”). Accept terminators across languages,
1706
+ // closing brackets/quotes, code fences, and common emoji endings.
1707
+ const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏]\s*$/u.test(t)
1708
+ || /```\s*$/.test(t)
1709
+ || /[}\]]\s*$/.test(t);
1710
+ return !endOk;
1711
+ }
1712
+ /** Current context usage percent for a session (totalTokens / contextTokens),
1713
+ * or 0 if it cannot be determined. Same basis as /status. */
1714
+ async getContextUsagePercent(sessionKey) {
1715
+ try {
1716
+ const resp = await this.openclawClient.getSessionInfo(sessionKey);
1717
+ const s = resp?.session;
1718
+ const total = s?.totalTokens || 0;
1719
+ const ctx = s?.contextTokens || 0;
1720
+ return ctx > 0 ? Math.round((total / ctx) * 100) : 0;
1721
+ }
1722
+ catch {
1723
+ return 0;
1724
+ }
1725
+ }
1726
+ /**
1727
+ * After a reply is delivered, alert the user once if context usage crossed the
1728
+ * threshold (default 80%), so they can compact before a session bloats into the
1729
+ * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
1730
+ * alerted we stay quiet until usage drops back below the threshold (e.g. after
1731
+ * a compact), then re-arm.
1732
+ */
1733
+ async maybeAlertHighContext(chatId) {
1734
+ const threshold = contextAlertPct();
1735
+ if (threshold <= 0)
1736
+ return;
1737
+ try {
1738
+ const sessionKey = this.getSessionKey(chatId);
1739
+ const pct = await this.getContextUsagePercent(sessionKey);
1740
+ if (pct <= 0)
1741
+ return;
1742
+ const alerted = this.contextAlerted.get(chatId) || false;
1743
+ if (pct >= threshold) {
1744
+ if (alerted)
1745
+ return; // already warned this cycle
1746
+ this.contextAlerted.set(chatId, true);
1747
+ const en = this.isEn(chatId);
1748
+ const text = en
1749
+ ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
1750
+ : `\u26a0\ufe0f \u4e0a\u4e0b\u6587\u5df2\u8fbe ${pct}%\uff08\u2265${threshold}%\uff09\uff0c\u5efa\u8bae\u5c3d\u5feb /compact \u538b\u7f29\uff0c\u907f\u514d\u8d85\u65f6/\u7206\u4e0a\u4e0b\u6587\u3002`;
1751
+ await this.sendMessage(chatId, text).catch(() => { });
1752
+ }
1753
+ else if (pct < threshold - 5) {
1754
+ // Re-arm once usage clearly dropped (hysteresis to avoid flapping).
1755
+ this.contextAlerted.set(chatId, false);
1756
+ }
1757
+ }
1758
+ catch {
1759
+ // best-effort; never block delivery
1760
+ }
1761
+ }
1762
+ /** True when the agent's reply is exactly a completion phrase (zh or en,
1763
+ * tolerant of trailing punctuation/whitespace and case). */
1764
+ /**
1765
+ * True when the agent's reply means “done”. We accept the exact phrase as well
1766
+ * as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
1767
+ * when a negation/not-finished cue is present (“还没结束” / “not done”), so a
1768
+ * “still working” answer never counts as completion.
1769
+ */
1770
+ isDonePhrase(text) {
1771
+ const raw = (text || "").trim();
1772
+ if (!raw)
1773
+ return false;
1774
+ // Short replies only — a long continuation is the model working, not confirming.
1775
+ if (raw.length > 24)
1776
+ return false;
1777
+ const stripped = raw.replace(/[。.!!~~\s“”"',,]+/gu, "");
1778
+ const zh = AUTO_RETRY_DONE_PHRASE; // e.g. 结束了
1779
+ const en = AUTO_RETRY_DONE_PHRASE_EN.toUpperCase(); // e.g. DONE
1780
+ const hasDone = stripped.includes(zh) || stripped.toUpperCase().includes(en);
1781
+ if (!hasDone)
1782
+ return false;
1783
+ // Reject if a not-finished / negation cue is present.
1784
+ if (/(没|未|不|还在|继续|not|isn'?t|aren'?t|no[t\s])/iu.test(raw))
1785
+ return false;
1786
+ return true;
1787
+ }
1788
+ /**
1789
+ * Auto-retry loop: if `reply` looks truncated, ask the same session whether it
1790
+ * finished. The session is the arbiter: if it replies exactly the done phrase,
1791
+ * the ORIGINAL reply was the real result and we deliver that. Otherwise the
1792
+ * session kept working and its new output becomes the latest reply, which we
1793
+ * re-check. Loops until the session confirms completion, a reply no longer
1794
+ * looks truncated, the budget is spent, or the session cannot answer.
1795
+ */
1796
+ async autoRetryUntilComplete(params) {
1797
+ const { chatId, sessionKey, senderName, triggerId, lastHumanMessageId, isNativeCommandTrigger, liveStatus } = params;
1798
+ let reply = params.initialReply ?? "";
1799
+ // Whether we still owe at least one probe regardless of how `reply` looks.
1800
+ // The timeout path starts in this state (no reply to inspect yet).
1801
+ let forceProbe = params.initialError !== undefined && !params.initialReply;
1802
+ if (!autoRetryEnabled() || isNativeCommandTrigger) {
1803
+ if (forceProbe)
1804
+ throw new Error(`Agent error: ${params.initialError}`);
1805
+ return reply;
1806
+ }
1807
+ const maxRetry = autoRetryMax();
1808
+ let compacted = false; // auto-compact at most once per auto-retry round
1809
+ let probedAtLeastOnce = false;
1810
+ for (let attempt = 1; attempt <= maxRetry; attempt++) {
1811
+ if (!forceProbe) {
1812
+ const parsed = this.extractBridgeAttachments(reply);
1813
+ const trimmed = parsed.text.trim();
1814
+ // Hard rule: a reply that produced attachments (image/file/doc) is a
1815
+ // finished product — never auto-retry it (and never risk dropping the
1816
+ // bridge attachment marker by replacing `reply` with a probe answer).
1817
+ if (parsed.attachments.length > 0)
1818
+ return reply;
1819
+ // Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
1820
+ // and runtime failures have their own handling upstream.
1821
+ if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1822
+ return reply;
1823
+ if (!this.looksTruncated(trimmed))
1824
+ return reply;
1825
+ }
1826
+ forceProbe = false; // consumed; subsequent rounds inspect the probe reply
1827
+ const en = this.isEn(chatId);
1828
+ await liveStatus?.progress(en
1829
+ ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
1830
+ : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
1831
+ // One short line, in the user's own voice (we intentionally do not change
1832
+ // 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}”,没结束就接着做。`;
1837
+ let probeReply;
1838
+ try {
1839
+ probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
1840
+ sessionKey,
1841
+ unsyncedMessages: [],
1842
+ currentMessage: probe,
1843
+ currentSenderName: senderName,
1844
+ deliver: false,
1845
+ timeoutMs: 1_800_000,
1846
+ includeContext: false,
1847
+ includeBridgeAttachmentHint: false,
1848
+ onProgress: (event) => liveStatus?.progress(event),
1849
+ }));
1850
+ probedAtLeastOnce = true;
1851
+ }
1852
+ catch (err) {
1853
+ // Confirmation round failed (timeout/unhealthy). If the session is heavy
1854
+ // (≥ threshold) and we have not compacted yet this round, auto-compact and
1855
+ // keep retrying — a bloated session is the usual cause of these timeouts.
1856
+ // Otherwise stop looping.
1857
+ console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
1858
+ if (!compacted) {
1859
+ const pct = await this.getContextUsagePercent(sessionKey);
1860
+ if (pct >= autoRetryCompactPct()) {
1861
+ const en = this.isEn(chatId);
1862
+ await liveStatus?.progress(en
1863
+ ? `🧹 Context is large (${pct}%) and the retry failed — auto-compacting…`
1864
+ : `🧹 上下文过大(${pct}%)且重试出错,正在自动压缩…`).catch(() => { });
1865
+ try {
1866
+ const r = await this.compactWithFallback(sessionKey);
1867
+ compacted = true; // mark as attempted regardless, to avoid re-compacting in a loop
1868
+ if (r.compacted) {
1869
+ console.log(`[${this.config.name}] auto-retry: compacted session (${r.method}${r.detail ? " " + r.detail : ""}) at ${pct}% for ${chatId.slice(-8)}; continuing`);
1870
+ forceProbe = !reply; // if we still have no reply (timeout path), keep probing
1871
+ continue; // retry the probe on the now-lighter session
1872
+ }
1873
+ // Compact was a no-op; do not spin.
1874
+ console.warn(`[${this.config.name}] auto-retry: compact was a no-op for ${chatId.slice(-8)} (${r.reason || "no reason"})`);
1875
+ }
1876
+ catch (compactErr) {
1877
+ console.warn(`[${this.config.name}] auto-retry compact failed for ${chatId.slice(-8)}:`, this.errorSummary(compactErr));
1878
+ }
1879
+ }
1880
+ }
1881
+ // No reply to fall back to (timeout path) and retries exhausted/failed:
1882
+ // re-throw so the caller surfaces the original failure.
1883
+ if (!reply)
1884
+ throw (err instanceof Error ? err : new Error(`Agent error: ${params.initialError || "retry failed"}`));
1885
+ return reply;
1886
+ }
1887
+ const probeText = this.extractBridgeAttachments(probeReply).text.trim();
1888
+ if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
1889
+ if (!reply)
1890
+ throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
1891
+ return reply; // session could not answer; deliver existing result
1892
+ }
1893
+ if (this.isDonePhrase(probeText)) {
1894
+ console.log(`[${this.config.name}] auto-retry: session confirmed done after ${attempt} check(s) for ${chatId.slice(-8)}`);
1895
+ // On the timeout path we have no original reply; the done confirmation
1896
+ // means the interrupted work actually finished — but we have nothing
1897
+ // user-visible to deliver, so treat it as NO_REPLY rather than fabricate.
1898
+ return reply || "NO_REPLY";
1899
+ }
1900
+ // Not done: the session kept working. Its output is the new latest reply.
1901
+ console.log(`[${this.config.name}] auto-retry: session continued (attempt ${attempt}) for ${chatId.slice(-8)}`);
1902
+ reply = probeReply;
1903
+ }
1904
+ console.warn(`[${this.config.name}] auto-retry budget (${maxRetry}) spent for ${chatId.slice(-8)}; delivering latest result`);
1905
+ void probedAtLeastOnce;
1906
+ if (!reply)
1907
+ throw new Error(`Agent error: ${params.initialError || "retry budget exhausted"}`);
1908
+ return reply;
1909
+ }
1606
1910
  cancelDelayedFailure(chatId) {
1607
1911
  const timer = this.delayedFailureTimers.get(chatId);
1608
1912
  if (timer)
@@ -1940,25 +2244,24 @@ export class FeishuBot {
1940
2244
  }
1941
2245
  buildLiveStatusCard(view, chatId) {
1942
2246
  const en = this.isEn(chatId);
1943
- // Finished (done/failed): keep it minimal — no header, no footer, just one
1944
- // compact body line with three signals: status emoji + tool-call count +
1945
- // total elapsed. This avoids taking up much screen space after the run ends.
1946
- if (view.state !== "running") {
1947
- const statusEmoji = view.state === "failed" ? "⚠️" : view.noReply ? "💤" : "✅";
2247
+ // Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
2248
+ // one compact grey line (status emoji + tool-call count + total elapsed) so
2249
+ // the finished card does not take up much screen space.
2250
+ if (view.state === "done") {
2251
+ const statusEmoji = view.noReply ? "💤" : "✅";
1948
2252
  const summary = en
1949
2253
  ? `${statusEmoji} ${view.toolCalls} tool call${view.toolCalls === 1 ? "" : "s"} · ⏱ ${view.elapsed}`
1950
2254
  : `${statusEmoji} 累计${view.toolCalls} 次工具调用 · ⏱ 耗时${view.elapsed}`;
1951
- // Grey + small like the footer, so the finished card stays unobtrusive.
1952
- const compact = `<font color='grey'>${summary}</font>`;
1953
2255
  return {
1954
2256
  schema: "2.0",
1955
2257
  config: { update_multi: true, width_mode: "fill" },
1956
- body: { elements: [{ tag: "markdown", content: compact }] },
2258
+ body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
1957
2259
  };
1958
2260
  }
1959
2261
  const elements = [];
1960
- // Running: recent activity lines (each prefixed with the relative time mm:ss
1961
- // since the run started).
2262
+ // Running OR failed: show the recent activity window (each line prefixed with
2263
+ // the relative time mm:ss). On failure we deliberately KEEP the last lines so
2264
+ // the steps leading up to the error/kill/timeout are visible for debugging.
1962
2265
  if (view.lines.length > 0) {
1963
2266
  const iconFor = (kind) => kind === "tool_start" ? "▸"
1964
2267
  : kind === "tool_end" ? "✓"
@@ -1984,6 +2287,7 @@ export class FeishuBot {
1984
2287
  if (view.model)
1985
2288
  footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
1986
2289
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2290
+ const template = view.state === "failed" ? "orange" : "blue";
1987
2291
  return {
1988
2292
  schema: "2.0",
1989
2293
  // update_multi is REQUIRED for im.message.patch to update the card; Feishu
@@ -1991,7 +2295,7 @@ export class FeishuBot {
1991
2295
  config: { update_multi: true, width_mode: "fill" },
1992
2296
  header: {
1993
2297
  title: { tag: "plain_text", content: view.title },
1994
- template: "blue",
2298
+ template,
1995
2299
  },
1996
2300
  body: { elements },
1997
2301
  };
@@ -2600,14 +2904,88 @@ export class FeishuBot {
2600
2904
  /**
2601
2905
  * Handle /compact command: compress session context.
2602
2906
  */
2907
+ /**
2908
+ * Compact a session with a fallback chain:
2909
+ * 1. OpenClaw's native (LLM-summary) compaction — best semantics.
2910
+ * 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
2911
+ * the transcript file to drop tool calls/results while keeping the
2912
+ * conversation. This needs no model, so it works no matter how large the
2913
+ * session is (the native path itself fails to fit an oversized session
2914
+ * into the model). A backup is always written before any file change.
2915
+ * Returns a structured result describing what happened.
2916
+ */
2917
+ async compactWithFallback(sessionKey) {
2918
+ // 1. Native compaction first.
2919
+ let nativeReason;
2920
+ try {
2921
+ const res = await this.openclawClient.compactSession(sessionKey);
2922
+ if (res?.compacted === true)
2923
+ return { compacted: true, method: "native" };
2924
+ nativeReason = typeof res?.reason === "string" ? res.reason : undefined;
2925
+ }
2926
+ catch (err) {
2927
+ nativeReason = err.message;
2928
+ }
2929
+ // 2. Fall back to tool-trim on the transcript file.
2930
+ let sessionId;
2931
+ try {
2932
+ const info = await this.openclawClient.getSessionInfo(sessionKey);
2933
+ sessionId = info?.session?.sessionId;
2934
+ }
2935
+ catch {
2936
+ // ignore; handled below
2937
+ }
2938
+ if (!sessionId) {
2939
+ return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
2940
+ }
2941
+ const filePath = resolveSessionFilePath(sessionKey, sessionId);
2942
+ if (!filePath) {
2943
+ return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
2944
+ }
2945
+ const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
2946
+ if (!trim.ok) {
2947
+ return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
2948
+ }
2949
+ const before = trim.bytesBefore || 0;
2950
+ const after = trim.bytesAfter || 0;
2951
+ if (before > 0 && after >= before) {
2952
+ // Nothing meaningful trimmed.
2953
+ return { compacted: false, method: "none", reason: trim.reason || "no tool content to trim" };
2954
+ }
2955
+ const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
2956
+ const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
2957
+ return {
2958
+ compacted: true,
2959
+ method: "tool-trim",
2960
+ detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
2961
+ };
2962
+ }
2603
2963
  async handleCompactCommand(chatId, messageId) {
2604
2964
  const sessionKey = this.getSessionKey(chatId);
2965
+ const en = this.isEn(chatId);
2605
2966
  try {
2606
- await this.openclawClient.compactSession(sessionKey);
2607
- await this.replyMessage(messageId, `✅ Session 已压缩`);
2967
+ const r = await this.compactWithFallback(sessionKey);
2968
+ 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 已压缩`);
2976
+ }
2977
+ }
2978
+ else {
2979
+ const reason = r.reason || (en ? "nothing to compact" : "无需压缩");
2980
+ await this.replyMessage(messageId, en
2981
+ ? `ℹ️ Session not compacted (${reason})`
2982
+ : `ℹ️ Session 未压缩(${reason})`);
2983
+ }
2608
2984
  }
2609
2985
  catch (err) {
2610
- await this.replyMessage(messageId, `❌ 压缩失败: ${err.message}`);
2986
+ await this.replyMessage(messageId, en
2987
+ ? `❌ Compact failed: ${err.message}`
2988
+ : `❌ 压缩失败: ${err.message}`);
2611
2989
  }
2612
2990
  }
2613
2991
  /**
@@ -256,11 +256,19 @@ export class LiveStatusController {
256
256
  title = en ? `\u26A0\uFE0F ${this.opts.botName} stopped` : `\u26A0\uFE0F ${this.opts.botName} \u6267\u884c\u4e2d\u65ad`;
257
257
  else
258
258
  title = en ? `${this.opts.botName} is working` : `${this.opts.botName} \u6b63\u5728\u6267\u884c`;
259
- // When finished, replace the recent-activity window with a run summary
260
- // (total tool calls + total time) instead of the last few messages.
261
- const lines = this.state === "running"
262
- ? [...this.lines]
263
- : [this.buildSummaryLine(en)];
259
+ // On a clean finish (done / NO_REPLY) show only the compact summary. On a
260
+ // failure (error / killed / timeout) keep the recent activity window too, so
261
+ // the last few steps before the failure are visible for debugging.
262
+ let lines;
263
+ if (this.state === "running") {
264
+ lines = [...this.lines];
265
+ }
266
+ else if (this.state === "failed") {
267
+ lines = [...this.lines, this.buildSummaryLine(en)];
268
+ }
269
+ else {
270
+ lines = [this.buildSummaryLine(en)];
271
+ }
264
272
  return {
265
273
  title,
266
274
  lines,
@@ -175,6 +175,17 @@ export class OpenClawClient {
175
175
  data: { text: textParts.join("\n") },
176
176
  });
177
177
  }
178
+ else if (state === "error") {
179
+ // A chat-level error (e.g. "Request timed out before a response")
180
+ // must NOT be silently dropped — otherwise mid-run delta text gets
181
+ // surfaced as if it were a normal reply and the run neither errors
182
+ // nor retries. Record it so the collect loop can surface/handle it.
183
+ this.agentEvents.get(sk).push({
184
+ ...frame.payload,
185
+ stream: "chatError",
186
+ data: { error: frame.payload?.errorMessage || frame.payload?.error || "chat error" },
187
+ });
188
+ }
178
189
  }
179
190
  else {
180
191
  this.agentEvents.get(sk).push(frame.payload);
@@ -835,6 +846,35 @@ export class OpenClawClient {
835
846
  reject(new Error(`Agent error: ${errText}`));
836
847
  return;
837
848
  }
849
+ if (ev.stream === "chatError") {
850
+ // Chat-level error (most commonly a request timeout). Treat it like
851
+ // a lifecycle error: recoverable ones defer (agent may auto-recover
852
+ // and still produce a real final); otherwise reject so the bridge
853
+ // surfaces the failure / lets auto-retry kick in — instead of
854
+ // surfacing whatever mid-run delta text happened to be collected.
855
+ const errText = String(ev.data?.error || "chat error");
856
+ if (this.isRecoverableAgentError(errText)) {
857
+ pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
858
+ resetIdleTimer();
859
+ console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
860
+ bucket.splice(i, 1);
861
+ continue;
862
+ }
863
+ clearTimeout(idleTimer);
864
+ clearInterval(poller);
865
+ if (chatFinalTimer)
866
+ clearTimeout(chatFinalTimer);
867
+ if (lifecycleEndTimer)
868
+ clearTimeout(lifecycleEndTimer);
869
+ // Abort the (possibly still-running) backgrounded run before we
870
+ // reject, so an auto-retry probe does not race a zombie run on the
871
+ // same session. Idempotent/best-effort.
872
+ this.abortChat(targetSessionKey || sessionKey, evRunId || runId).catch((abortErr) => {
873
+ console.warn(`[OpenClaw] abort after chat error failed:`, abortErr.message);
874
+ });
875
+ reject(new Error(`Agent error: ${errText}`));
876
+ return;
877
+ }
838
878
  }
839
879
  }
840
880
  }, 50);
@@ -0,0 +1,39 @@
1
+ export type ToolTrimResult = {
2
+ ok: boolean;
3
+ reason?: string;
4
+ /** Bytes before / after, and line counts, for reporting. */
5
+ bytesBefore?: number;
6
+ bytesAfter?: number;
7
+ linesBefore?: number;
8
+ linesAfter?: number;
9
+ removedToolResults?: number;
10
+ strippedToolCalls?: number;
11
+ removedEmptyAssistants?: number;
12
+ keptRecentToolCalls?: number;
13
+ backupPath?: string;
14
+ };
15
+ /**
16
+ * Resolve the on-disk OpenClaw transcript file for a session.
17
+ *
18
+ * Session key looks like `agent:<agentId>:<label>` (e.g.
19
+ * `agent:main:lma-claude-oc_xxx`). The transcript lives at
20
+ * `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
21
+ */
22
+ export declare function resolveSessionFilePath(sessionKey: string, sessionId: string, openclawHome?: string): string | null;
23
+ /**
24
+ * "Tool-trim" compaction: rewrite a session transcript in place, removing the
25
+ * tool-call noise while keeping the conversation intact:
26
+ * - drop every toolResult message entirely;
27
+ * - strip the `toolCall` parts from assistant messages but KEEP their text
28
+ * (the model's "thinking out loud while acting"); if an assistant message
29
+ * had only tool calls and no text, drop the whole line;
30
+ * - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
31
+ * tool call, so the loader is not confused.
32
+ *
33
+ * This does NOT call any model, so it works no matter how large the session is
34
+ * (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
35
+ * oversized session into the model). A backup is always written first.
36
+ *
37
+ * Typical reduction is ~70-90% for tool-heavy sessions.
38
+ */
39
+ export declare function toolTrimCompactFile(filePath: string, keepRecentToolCalls?: number): ToolTrimResult;
@@ -0,0 +1,176 @@
1
+ import { existsSync, copyFileSync, readFileSync, writeFileSync, renameSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+ /**
5
+ * Resolve the on-disk OpenClaw transcript file for a session.
6
+ *
7
+ * Session key looks like `agent:<agentId>:<label>` (e.g.
8
+ * `agent:main:lma-claude-oc_xxx`). The transcript lives at
9
+ * `<openclawHome>/agents/<agentId>/sessions/<sessionId>.jsonl`.
10
+ */
11
+ export function resolveSessionFilePath(sessionKey, sessionId, openclawHome) {
12
+ if (!sessionId)
13
+ return null;
14
+ const home = openclawHome
15
+ || process.env.OPENCLAW_HOME
16
+ || process.env.OPENCLAW_CONFIG_DIR
17
+ || join(homedir(), ".openclaw");
18
+ // agentId is the second segment of agent:<agentId>:<label>; default to "main".
19
+ const parts = sessionKey.split(":");
20
+ const agentId = parts.length >= 3 && parts[0] === "agent" ? parts[1] : "main";
21
+ const file = join(home, "agents", agentId, "sessions", `${sessionId}.jsonl`);
22
+ return existsSync(file) ? file : null;
23
+ }
24
+ /**
25
+ * "Tool-trim" compaction: rewrite a session transcript in place, removing the
26
+ * tool-call noise while keeping the conversation intact:
27
+ * - drop every toolResult message entirely;
28
+ * - strip the `toolCall` parts from assistant messages but KEEP their text
29
+ * (the model's "thinking out loud while acting"); if an assistant message
30
+ * had only tool calls and no text, drop the whole line;
31
+ * - fix `stopReason: "toolUse"` -> "stop" on messages that no longer have a
32
+ * tool call, so the loader is not confused.
33
+ *
34
+ * This does NOT call any model, so it works no matter how large the session is
35
+ * (unlike OpenClaw's LLM-summary compaction, which itself fails to fit an
36
+ * oversized session into the model). A backup is always written first.
37
+ *
38
+ * Typical reduction is ~70-90% for tool-heavy sessions.
39
+ */
40
+ export function toolTrimCompactFile(filePath, keepRecentToolCalls = 3) {
41
+ if (!existsSync(filePath))
42
+ return { ok: false, reason: "transcript file not found" };
43
+ const original = readFileSync(filePath, "utf8");
44
+ const lines = original.split("\n");
45
+ const bytesBefore = Buffer.byteLength(original, "utf8");
46
+ // First pass: collect every toolCall id in order, so we can keep the most
47
+ // recent N of them (and their matching toolResults) intact. Recent tool calls
48
+ // are often still relevant to what the agent is doing next.
49
+ const toolCallIds = [];
50
+ for (const line of lines) {
51
+ if (line.trim() === "")
52
+ continue;
53
+ let d;
54
+ try {
55
+ d = JSON.parse(line);
56
+ }
57
+ catch {
58
+ continue;
59
+ }
60
+ if (d?.type !== "message")
61
+ continue;
62
+ const msg = d.message;
63
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) {
64
+ for (const p of msg.content) {
65
+ if (p && typeof p === "object" && p.type === "toolCall" && p.id)
66
+ toolCallIds.push(String(p.id));
67
+ }
68
+ }
69
+ }
70
+ const keepN = Math.max(0, Math.floor(keepRecentToolCalls));
71
+ const keepIds = new Set(keepN > 0 ? toolCallIds.slice(-keepN) : []);
72
+ const out = [];
73
+ let removedToolResults = 0;
74
+ let strippedToolCalls = 0;
75
+ let removedEmptyAssistants = 0;
76
+ let keptRecentToolCalls = 0;
77
+ let linesBefore = 0;
78
+ for (const line of lines) {
79
+ if (line.trim() === "") {
80
+ continue;
81
+ } // drop blank lines; we re-join with \n
82
+ linesBefore++;
83
+ let d;
84
+ try {
85
+ d = JSON.parse(line);
86
+ }
87
+ catch {
88
+ out.push(line);
89
+ continue;
90
+ }
91
+ if (d?.type !== "message") {
92
+ out.push(line);
93
+ continue;
94
+ }
95
+ const msg = d.message;
96
+ const role = msg?.role;
97
+ if (role === "toolResult") {
98
+ // Keep the toolResult only if its toolCall is in the recent keep-set.
99
+ if (msg?.toolCallId && keepIds.has(String(msg.toolCallId))) {
100
+ out.push(line);
101
+ continue;
102
+ }
103
+ removedToolResults++;
104
+ continue;
105
+ }
106
+ if (role === "assistant" && Array.isArray(msg?.content)) {
107
+ const content = msg.content;
108
+ const hasToolCall = content.some((p) => p && typeof p === "object" && p.type === "toolCall");
109
+ if (hasToolCall) {
110
+ // Keep toolCalls that are in the recent keep-set; strip the rest.
111
+ const kept = content.filter((p) => {
112
+ if (!p || typeof p !== "object")
113
+ return false;
114
+ if (p.type !== "toolCall")
115
+ return true;
116
+ return p.id && keepIds.has(String(p.id));
117
+ });
118
+ const keptToolCall = kept.some((p) => p.type === "toolCall");
119
+ const hasText = kept.some((p) => p.type === "text" && typeof p.text === "string" && p.text.trim() !== "");
120
+ if (!keptToolCall && !hasText) {
121
+ removedEmptyAssistants++;
122
+ continue;
123
+ }
124
+ if (keptToolCall) {
125
+ keptRecentToolCalls++;
126
+ // stopReason stays toolUse since a toolCall remains.
127
+ if (kept.length !== content.length)
128
+ msg.content = kept;
129
+ out.push(JSON.stringify(d));
130
+ continue;
131
+ }
132
+ // All toolCalls stripped: keep text only, fix stopReason.
133
+ msg.content = kept;
134
+ if (msg.stopReason === "toolUse")
135
+ msg.stopReason = "stop";
136
+ strippedToolCalls++;
137
+ out.push(JSON.stringify(d));
138
+ continue;
139
+ }
140
+ }
141
+ out.push(line);
142
+ }
143
+ // Safety: verify every output line is valid JSON before committing.
144
+ for (const l of out) {
145
+ try {
146
+ JSON.parse(l);
147
+ }
148
+ catch {
149
+ return { ok: false, reason: "produced invalid JSON; aborted" };
150
+ }
151
+ }
152
+ if (removedToolResults === 0 && strippedToolCalls === 0 && removedEmptyAssistants === 0) {
153
+ return { ok: true, reason: "no tool content to trim", bytesBefore, bytesAfter: bytesBefore, linesBefore, linesAfter: out.length, keptRecentToolCalls };
154
+ }
155
+ // Always back up first.
156
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
157
+ const backupPath = `${filePath}.bak-tooltrim-${stamp}`;
158
+ copyFileSync(filePath, backupPath);
159
+ // Atomic write: temp file + rename.
160
+ const body = out.join("\n") + "\n";
161
+ const tmp = `${filePath}.tmp-tooltrim`;
162
+ writeFileSync(tmp, body, "utf8");
163
+ renameSync(tmp, filePath);
164
+ return {
165
+ ok: true,
166
+ bytesBefore,
167
+ bytesAfter: Buffer.byteLength(body, "utf8"),
168
+ linesBefore,
169
+ linesAfter: out.length,
170
+ removedToolResults,
171
+ strippedToolCalls,
172
+ removedEmptyAssistants,
173
+ keptRecentToolCalls,
174
+ backupPath,
175
+ };
176
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
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": {