openclaw-lark-multi-agent 1.1.2 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,11 +3,13 @@ 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";
9
10
  import { buildFeishuCardElements } from "./markdown.js";
10
11
  import { discussionManager } from "./discussion-manager.js";
12
+ import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
11
13
  const require = createRequire(import.meta.url);
12
14
  const LMA_VERSION = require("../package.json").version;
13
15
  const MAX_BOT_STREAK = 10;
@@ -17,6 +19,26 @@ const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
17
19
  const FEISHU_DOCS_DIR = join(getDataDir(), "feishu-docs");
18
20
  const SESSION_HEALTH_POLL_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_POLL_MS || 5_000);
19
21
  const SESSION_HEALTH_CONFIRM_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_SESSION_HEALTH_CONFIRM_MS || 2_000);
22
+ // Auto-retry: when a reply looks truncated/incomplete, ask the same session
23
+ // whether it finished. If it did not, let it continue; loop until it confirms
24
+ // completion or the retry budget is spent. Off by setting AUTO_RETRY=0.
25
+ // Read at call time (not module load) so tests can toggle it per-case.
26
+ function autoRetryEnabled() { return process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY !== "0"; }
27
+ function autoRetryMax() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_MAX || 5); }
28
+ // The exact phrase the agent must reply to confirm completion. The probe asks
29
+ // for the locale-appropriate phrase; detection accepts BOTH so either works.
30
+ const AUTO_RETRY_DONE_PHRASE = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE || "\u7ed3\u675f\u4e86";
31
+ const AUTO_RETRY_DONE_PHRASE_EN = process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_DONE_PHRASE_EN || "DONE";
32
+ // When an auto-retry probe errors AND context usage is at/above this percent,
33
+ // auto-compact the session once and keep retrying (instead of giving up). This
34
+ // rescues the “session too heavy → run times out / lock contention” failure mode.
35
+ function autoRetryCompactPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_AUTO_RETRY_COMPACT_PCT || 50); }
36
+ // How many of the most-recent tool calls (and their results) tool-trim keeps
37
+ // intact — recent tool calls may still be relevant to the agent's next step.
38
+ function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
39
+ // After a reply is delivered, if context usage reaches this percent, send the
40
+ // user a one-time alert to compact. 0 disables the alert.
41
+ function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
20
42
  class UnhealthySessionError extends Error {
21
43
  status;
22
44
  constructor(status) {
@@ -58,6 +80,8 @@ export class FeishuBot {
58
80
  delayedFailureTimers = new Map();
59
81
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
60
82
  lastRealDeliveryAt = new Map();
83
+ /** Per-chat: whether we've already sent the high-context alert this cycle. */
84
+ contextAlerted = new Map();
61
85
  /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
62
86
  activeDeliveryTargets = new Map();
63
87
  adminOpenId;
@@ -1220,6 +1244,31 @@ export class FeishuBot {
1220
1244
  onProgress: (event) => liveStatus?.progress(event),
1221
1245
  }));
1222
1246
  }
1247
+ catch (mainErr) {
1248
+ // The main run failed before returning a reply. If it is a retryable
1249
+ // error (e.g. a request timeout) and not a session-health failure,
1250
+ // reuse the SAME auto-retry mechanism to let the session resume from
1251
+ // where it was interrupted, instead of giving up immediately.
1252
+ if (mainErr instanceof UnhealthySessionError)
1253
+ throw mainErr;
1254
+ const errText = mainErr?.message || String(mainErr);
1255
+ if (autoRetryEnabled() && !isNativeCommandTrigger && this.isRetryableAgentError(errText)) {
1256
+ console.warn(`[${this.config.name}] main run failed (${errText.slice(0, 80)}) for ${chatId.slice(-8)}; entering auto-retry`);
1257
+ reply = await this.autoRetryUntilComplete({
1258
+ chatId,
1259
+ sessionKey,
1260
+ senderName: lastHuman.senderName,
1261
+ triggerId,
1262
+ lastHumanMessageId: lastHuman.messageId || "",
1263
+ isNativeCommandTrigger,
1264
+ initialError: errText,
1265
+ liveStatus,
1266
+ });
1267
+ }
1268
+ else {
1269
+ throw mainErr;
1270
+ }
1271
+ }
1223
1272
  finally {
1224
1273
  // OpenClaw may emit the final assistant session.message just after
1225
1274
  // collectReply returns. Keep the trigger mapping briefly so proactive
@@ -1227,6 +1276,19 @@ export class FeishuBot {
1227
1276
  releaseActiveDeliveryTarget();
1228
1277
  }
1229
1278
  console.log(`[${this.config.name}] OpenClaw reply collected for ${chatId.slice(-8)} in ${Date.now() - queueStartedAt}ms`);
1279
+ // Auto-retry: if the reply looks truncated/incomplete, confirm with the
1280
+ // session and loop until it reports done (or the budget is spent). This
1281
+ // replaces `reply` with the final, confirmed result before delivery.
1282
+ reply = await this.autoRetryUntilComplete({
1283
+ chatId,
1284
+ sessionKey,
1285
+ senderName: lastHuman.senderName,
1286
+ triggerId,
1287
+ lastHumanMessageId: lastHuman.messageId || "",
1288
+ isNativeCommandTrigger,
1289
+ initialReply: reply,
1290
+ liveStatus,
1291
+ });
1230
1292
  const parsedReply = this.extractBridgeAttachments(reply);
1231
1293
  const visibleReply = parsedReply.text;
1232
1294
  const trimmedReply = visibleReply.trim();
@@ -1325,6 +1387,8 @@ export class FeishuBot {
1325
1387
  // not re-process any of them.
1326
1388
  for (const id of mergedTriggerIds)
1327
1389
  this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
1390
+ // After a successful reply, warn the user once if context is high.
1391
+ void this.maybeAlertHighContext(chatId);
1328
1392
  }
1329
1393
  catch (err) {
1330
1394
  // enqueueAndDispatchDelivery already sent a user-visible delivery
@@ -1603,6 +1667,247 @@ export class FeishuBot {
1603
1667
  isRuntimeFailureText(text) {
1604
1668
  return text.startsWith("⚠️ Agent 未正常完成") || /\n原因:\s*rpc\b/.test(text);
1605
1669
  }
1670
+ /**
1671
+ * Is this main-run error worth auto-retrying (by resuming the session via the
1672
+ * probe loop)? Mainly transient failures — request/response timeouts and
1673
+ * aborted/incomplete runs. We do NOT auto-retry clearly terminal errors
1674
+ * (auth/permission/quota/billing/model-not-supported), which would just fail
1675
+ * again. The error text comes from collectReply's `Agent error: <msg>`.
1676
+ */
1677
+ isRetryableAgentError(errText) {
1678
+ const t = (errText || "").toLowerCase();
1679
+ // Terminal errors: never auto-retry.
1680
+ if (/(unauthorized|permission|forbidden|invalid api key|quota|billing|insufficient|model_not_supported|not supported|invalid_request)/.test(t))
1681
+ return false;
1682
+ // Retryable: timeouts and transient interruptions.
1683
+ return /(timed out|timeout|aborted|operation was aborted|no response|request failed|temporarily|connection|econnreset|socket hang|stream)/.test(t);
1684
+ }
1685
+ /**
1686
+ * Heuristic: does this reply look truncated / mid-task? Bias toward false
1687
+ * positives (“宁可错杀不放过”) — a false positive only costs one invisible
1688
+ * confirmation round, and the session self-check is the real arbiter. We gate
1689
+ * only on cheap text shape here, never on stopReason (the upstream rarely
1690
+ * provides one for normal-looking-but-truncated finals).
1691
+ */
1692
+ looksTruncated(text) {
1693
+ const t = (text || "").trim();
1694
+ if (!t)
1695
+ return false; // empty/NO_REPLY handled elsewhere
1696
+ // Unbalanced code fence => almost certainly cut off mid-block.
1697
+ const fences = (t.match(/```/g) || []).length;
1698
+ if (fences % 2 === 1)
1699
+ return true;
1700
+ // Ends with an obvious continuation cue (dangling connector / lead-in).
1701
+ if (/[,,::、;;]\s*$/u.test(t))
1702
+ return true;
1703
+ if (/(接下来|然后|首先|现在我|让我|下一步|let me|now i|next,?|first,?)\s*$/iu.test(t))
1704
+ return true;
1705
+ // Core signal: ends without any sentence-ending punctuation / closer
1706
+ // (“连句号都没有就感觉没成功”). Accept terminators across languages,
1707
+ // closing brackets/quotes, code fences, and common emoji endings.
1708
+ const endOk = /[。!?.!?…)\]」』】)"'`”’~~—✨✅👍🙏]\s*$/u.test(t)
1709
+ || /```\s*$/.test(t)
1710
+ || /[}\]]\s*$/.test(t);
1711
+ return !endOk;
1712
+ }
1713
+ /** Current context usage percent for a session (totalTokens / contextTokens),
1714
+ * or 0 if it cannot be determined. Same basis as /status. */
1715
+ async getContextUsagePercent(sessionKey) {
1716
+ try {
1717
+ const resp = await this.openclawClient.getSessionInfo(sessionKey);
1718
+ const s = resp?.session;
1719
+ const total = s?.totalTokens || 0;
1720
+ const ctx = s?.contextTokens || 0;
1721
+ return ctx > 0 ? Math.round((total / ctx) * 100) : 0;
1722
+ }
1723
+ catch {
1724
+ return 0;
1725
+ }
1726
+ }
1727
+ /**
1728
+ * After a reply is delivered, alert the user once if context usage crossed the
1729
+ * threshold (default 80%), so they can compact before a session bloats into the
1730
+ * timeout/lock/overflow failure modes. We only alert on a fresh crossing: once
1731
+ * alerted we stay quiet until usage drops back below the threshold (e.g. after
1732
+ * a compact), then re-arm.
1733
+ */
1734
+ async maybeAlertHighContext(chatId) {
1735
+ const threshold = contextAlertPct();
1736
+ if (threshold <= 0)
1737
+ return;
1738
+ try {
1739
+ const sessionKey = this.getSessionKey(chatId);
1740
+ const pct = await this.getContextUsagePercent(sessionKey);
1741
+ if (pct <= 0)
1742
+ return;
1743
+ const alerted = this.contextAlerted.get(chatId) || false;
1744
+ if (pct >= threshold) {
1745
+ if (alerted)
1746
+ return; // already warned this cycle
1747
+ this.contextAlerted.set(chatId, true);
1748
+ const en = this.isEn(chatId);
1749
+ const text = en
1750
+ ? `\u26a0\ufe0f Context is at ${pct}% (\u2265${threshold}%). Consider /compact soon to avoid timeouts/overflow.`
1751
+ : `\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`;
1752
+ await this.sendMessage(chatId, text).catch(() => { });
1753
+ }
1754
+ else if (pct < threshold - 5) {
1755
+ // Re-arm once usage clearly dropped (hysteresis to avoid flapping).
1756
+ this.contextAlerted.set(chatId, false);
1757
+ }
1758
+ }
1759
+ catch {
1760
+ // best-effort; never block delivery
1761
+ }
1762
+ }
1763
+ /** True when the agent's reply is exactly a completion phrase (zh or en,
1764
+ * tolerant of trailing punctuation/whitespace and case). */
1765
+ /**
1766
+ * True when the agent's reply means “done”. We accept the exact phrase as well
1767
+ * as common wrappers (“已经结束了” / “好的,结束了” / “yes, DONE”), but reject it
1768
+ * when a negation/not-finished cue is present (“还没结束” / “not done”), so a
1769
+ * “still working” answer never counts as completion.
1770
+ */
1771
+ isDonePhrase(text) {
1772
+ const raw = (text || "").trim();
1773
+ if (!raw)
1774
+ return false;
1775
+ // Short replies only — a long continuation is the model working, not confirming.
1776
+ if (raw.length > 24)
1777
+ return false;
1778
+ const stripped = raw.replace(/[。.!!~~\s“”"',,]+/gu, "");
1779
+ const zh = AUTO_RETRY_DONE_PHRASE; // e.g. 结束了
1780
+ const en = AUTO_RETRY_DONE_PHRASE_EN.toUpperCase(); // e.g. DONE
1781
+ const hasDone = stripped.includes(zh) || stripped.toUpperCase().includes(en);
1782
+ if (!hasDone)
1783
+ return false;
1784
+ // Reject if a not-finished / negation cue is present.
1785
+ if (/(没|未|不|还在|继续|not|isn'?t|aren'?t|no[t\s])/iu.test(raw))
1786
+ return false;
1787
+ return true;
1788
+ }
1789
+ /**
1790
+ * Auto-retry loop: if `reply` looks truncated, ask the same session whether it
1791
+ * finished. The session is the arbiter: if it replies exactly the done phrase,
1792
+ * the ORIGINAL reply was the real result and we deliver that. Otherwise the
1793
+ * session kept working and its new output becomes the latest reply, which we
1794
+ * re-check. Loops until the session confirms completion, a reply no longer
1795
+ * looks truncated, the budget is spent, or the session cannot answer.
1796
+ */
1797
+ async autoRetryUntilComplete(params) {
1798
+ const { chatId, sessionKey, senderName, triggerId, lastHumanMessageId, isNativeCommandTrigger, liveStatus } = params;
1799
+ let reply = params.initialReply ?? "";
1800
+ // Whether we still owe at least one probe regardless of how `reply` looks.
1801
+ // The timeout path starts in this state (no reply to inspect yet).
1802
+ let forceProbe = params.initialError !== undefined && !params.initialReply;
1803
+ if (!autoRetryEnabled() || isNativeCommandTrigger) {
1804
+ if (forceProbe)
1805
+ throw new Error(`Agent error: ${params.initialError}`);
1806
+ return reply;
1807
+ }
1808
+ const maxRetry = autoRetryMax();
1809
+ let compacted = false; // auto-compact at most once per auto-retry round
1810
+ let probedAtLeastOnce = false;
1811
+ for (let attempt = 1; attempt <= maxRetry; attempt++) {
1812
+ if (!forceProbe) {
1813
+ const parsed = this.extractBridgeAttachments(reply);
1814
+ const trimmed = parsed.text.trim();
1815
+ // Hard rule: a reply that produced attachments (image/file/doc) is a
1816
+ // finished product — never auto-retry it (and never risk dropping the
1817
+ // bridge attachment marker by replacing `reply` with a probe answer).
1818
+ if (parsed.attachments.length > 0)
1819
+ return reply;
1820
+ // Only engage for plausibly-successful-but-truncated text. Empty/NO_REPLY
1821
+ // and runtime failures have their own handling upstream.
1822
+ if (!trimmed || trimmed.toUpperCase() === "NO_REPLY" || this.isRuntimeFailureText(trimmed))
1823
+ return reply;
1824
+ if (!this.looksTruncated(trimmed))
1825
+ return reply;
1826
+ }
1827
+ forceProbe = false; // consumed; subsequent rounds inspect the probe reply
1828
+ const en = this.isEn(chatId);
1829
+ await liveStatus?.progress(en
1830
+ ? `⚠️ Reply looks incomplete — auto-checking (${attempt}/${maxRetry})…`
1831
+ : `⚠️ 疑似任务未完成,正在自动重试(${attempt}/${maxRetry})…`).catch(() => { });
1832
+ // One short line, in the user's own voice (we intentionally do not change
1833
+ // senderName). Each locale uses its own done-phrase so the English probe
1834
+ // stays fully English; detection accepts both phrases.
1835
+ const probe = en
1836
+ ? `Is that done? If so just reply "${AUTO_RETRY_DONE_PHRASE_EN}", otherwise keep going.`
1837
+ : `刚才那个任务结束了吗?结束了就回我“${AUTO_RETRY_DONE_PHRASE}”,没结束就接着做。`;
1838
+ let probeReply;
1839
+ try {
1840
+ probeReply = await this.withSessionHealthMonitor(chatId, lastHumanMessageId, triggerId, this.openclawClient.chatSendWithContext({
1841
+ sessionKey,
1842
+ unsyncedMessages: [],
1843
+ currentMessage: probe,
1844
+ currentSenderName: senderName,
1845
+ deliver: false,
1846
+ timeoutMs: 1_800_000,
1847
+ includeContext: false,
1848
+ includeBridgeAttachmentHint: false,
1849
+ onProgress: (event) => liveStatus?.progress(event),
1850
+ }));
1851
+ probedAtLeastOnce = true;
1852
+ }
1853
+ catch (err) {
1854
+ // Confirmation round failed (timeout/unhealthy). If the session is heavy
1855
+ // (≥ threshold) and we have not compacted yet this round, auto-compact and
1856
+ // keep retrying — a bloated session is the usual cause of these timeouts.
1857
+ // Otherwise stop looping.
1858
+ console.warn(`[${this.config.name}] auto-retry probe failed for ${chatId.slice(-8)}:`, this.errorSummary(err));
1859
+ if (!compacted) {
1860
+ const pct = await this.getContextUsagePercent(sessionKey);
1861
+ if (pct >= autoRetryCompactPct()) {
1862
+ const en = this.isEn(chatId);
1863
+ await liveStatus?.progress(en
1864
+ ? `🧹 Context is large (${pct}%) and the retry failed — auto-compacting…`
1865
+ : `🧹 上下文过大(${pct}%)且重试出错,正在自动压缩…`).catch(() => { });
1866
+ try {
1867
+ const r = await this.compactWithFallback(sessionKey);
1868
+ compacted = true; // mark as attempted regardless, to avoid re-compacting in a loop
1869
+ if (r.compacted) {
1870
+ console.log(`[${this.config.name}] auto-retry: compacted session (${r.method}${r.detail ? " " + r.detail : ""}) at ${pct}% for ${chatId.slice(-8)}; continuing`);
1871
+ forceProbe = !reply; // if we still have no reply (timeout path), keep probing
1872
+ continue; // retry the probe on the now-lighter session
1873
+ }
1874
+ // Compact was a no-op; do not spin.
1875
+ console.warn(`[${this.config.name}] auto-retry: compact was a no-op for ${chatId.slice(-8)} (${r.reason || "no reason"})`);
1876
+ }
1877
+ catch (compactErr) {
1878
+ console.warn(`[${this.config.name}] auto-retry compact failed for ${chatId.slice(-8)}:`, this.errorSummary(compactErr));
1879
+ }
1880
+ }
1881
+ }
1882
+ // No reply to fall back to (timeout path) and retries exhausted/failed:
1883
+ // re-throw so the caller surfaces the original failure.
1884
+ if (!reply)
1885
+ throw (err instanceof Error ? err : new Error(`Agent error: ${params.initialError || "retry failed"}`));
1886
+ return reply;
1887
+ }
1888
+ const probeText = this.extractBridgeAttachments(probeReply).text.trim();
1889
+ if (!probeText || probeText.toUpperCase() === "NO_REPLY") {
1890
+ if (!reply)
1891
+ throw new Error(`Agent error: ${params.initialError || "no reply after retry"}`);
1892
+ return reply; // session could not answer; deliver existing result
1893
+ }
1894
+ if (this.isDonePhrase(probeText)) {
1895
+ console.log(`[${this.config.name}] auto-retry: session confirmed done after ${attempt} check(s) for ${chatId.slice(-8)}`);
1896
+ // On the timeout path we have no original reply; the done confirmation
1897
+ // means the interrupted work actually finished — but we have nothing
1898
+ // user-visible to deliver, so treat it as NO_REPLY rather than fabricate.
1899
+ return reply || "NO_REPLY";
1900
+ }
1901
+ // Not done: the session kept working. Its output is the new latest reply.
1902
+ console.log(`[${this.config.name}] auto-retry: session continued (attempt ${attempt}) for ${chatId.slice(-8)}`);
1903
+ reply = probeReply;
1904
+ }
1905
+ console.warn(`[${this.config.name}] auto-retry budget (${maxRetry}) spent for ${chatId.slice(-8)}; delivering latest result`);
1906
+ void probedAtLeastOnce;
1907
+ if (!reply)
1908
+ throw new Error(`Agent error: ${params.initialError || "retry budget exhausted"}`);
1909
+ return reply;
1910
+ }
1606
1911
  cancelDelayedFailure(chatId) {
1607
1912
  const timer = this.delayedFailureTimers.get(chatId);
1608
1913
  if (timer)
@@ -1940,25 +2245,24 @@ export class FeishuBot {
1940
2245
  }
1941
2246
  buildLiveStatusCard(view, chatId) {
1942
2247
  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 ? "💤" : "✅";
2248
+ // Clean finish (done / NO_REPLY): keep it minimal — no header, no footer, just
2249
+ // one compact grey line (status emoji + tool-call count + total elapsed) so
2250
+ // the finished card does not take up much screen space.
2251
+ if (view.state === "done") {
2252
+ const statusEmoji = view.noReply ? "💤" : "✅";
1948
2253
  const summary = en
1949
2254
  ? `${statusEmoji} ${view.toolCalls} tool call${view.toolCalls === 1 ? "" : "s"} · ⏱ ${view.elapsed}`
1950
2255
  : `${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
2256
  return {
1954
2257
  schema: "2.0",
1955
2258
  config: { update_multi: true, width_mode: "fill" },
1956
- body: { elements: [{ tag: "markdown", content: compact }] },
2259
+ body: { elements: [{ tag: "markdown", content: `<font color='grey'>${summary}</font>` }] },
1957
2260
  };
1958
2261
  }
1959
2262
  const elements = [];
1960
- // Running: recent activity lines (each prefixed with the relative time mm:ss
1961
- // since the run started).
2263
+ // Running OR failed: show the recent activity window (each line prefixed with
2264
+ // the relative time mm:ss). On failure we deliberately KEEP the last lines so
2265
+ // the steps leading up to the error/kill/timeout are visible for debugging.
1962
2266
  if (view.lines.length > 0) {
1963
2267
  const iconFor = (kind) => kind === "tool_start" ? "▸"
1964
2268
  : kind === "tool_end" ? "✓"
@@ -1984,6 +2288,7 @@ export class FeishuBot {
1984
2288
  if (view.model)
1985
2289
  footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
1986
2290
  elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2291
+ const template = view.state === "failed" ? "orange" : "blue";
1987
2292
  return {
1988
2293
  schema: "2.0",
1989
2294
  // update_multi is REQUIRED for im.message.patch to update the card; Feishu
@@ -1991,7 +2296,7 @@ export class FeishuBot {
1991
2296
  config: { update_multi: true, width_mode: "fill" },
1992
2297
  header: {
1993
2298
  title: { tag: "plain_text", content: view.title },
1994
- template: "blue",
2299
+ template,
1995
2300
  },
1996
2301
  body: { elements },
1997
2302
  };
@@ -2035,6 +2340,73 @@ export class FeishuBot {
2035
2340
  data: { content: JSON.stringify(this.buildLiveStatusCard(view, chatId)) },
2036
2341
  });
2037
2342
  }
2343
+ /**
2344
+ * Render the /compact progress card. While running it shows a phase line plus a
2345
+ * ticking elapsed footer so the user can see compaction is in progress; when
2346
+ * terminal it collapses to a single compact line (success/skip/fail).
2347
+ */
2348
+ buildCompactProgressCard(view, chatId) {
2349
+ const en = this.isEn(chatId);
2350
+ if (view.state !== "running") {
2351
+ let line;
2352
+ if (view.state === "done") {
2353
+ line = en
2354
+ ? `✅ Session compacted${view.detail ? ` (${view.detail})` : ""} · ⏱ ${view.elapsed}`
2355
+ : `✅ Session 已压缩${view.detail ? `(${view.detail})` : ""} · ⏱ 耗时 ${view.elapsed}`;
2356
+ }
2357
+ else if (view.state === "noop") {
2358
+ line = en
2359
+ ? `ℹ️ Session not compacted${view.detail ? ` (${view.detail})` : ""}`
2360
+ : `ℹ️ Session 未压缩${view.detail ? `(${view.detail})` : ""}`;
2361
+ }
2362
+ else {
2363
+ line = en
2364
+ ? `❌ Compact failed${view.detail ? `: ${view.detail}` : ""}`
2365
+ : `❌ 压缩失败${view.detail ? `:${view.detail}` : ""}`;
2366
+ }
2367
+ return {
2368
+ schema: "2.0",
2369
+ config: { update_multi: true, width_mode: "fill" },
2370
+ body: { elements: [{ tag: "markdown", content: `<font color='grey'>${this.escapeCardText(line)}</font>` }] },
2371
+ };
2372
+ }
2373
+ // Running: phase line + ticking elapsed footer.
2374
+ const phaseLine = view.phase === "tool-trim"
2375
+ ? (en ? "🧹 Native compaction is slow — switching to fast trim…" : "🧹 原生压缩较慢,已切换快速压缩…")
2376
+ : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
2377
+ const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2378
+ return {
2379
+ schema: "2.0",
2380
+ config: { update_multi: true, width_mode: "fill" },
2381
+ header: {
2382
+ title: { tag: "plain_text", content: en ? "Compacting" : "正在压缩" },
2383
+ template: "blue",
2384
+ },
2385
+ body: {
2386
+ elements: [
2387
+ { tag: "markdown", content: this.escapeCardText(phaseLine) },
2388
+ { tag: "hr" },
2389
+ { tag: "markdown", content: `<font color='grey'>${footer}</font>` },
2390
+ ],
2391
+ },
2392
+ };
2393
+ }
2394
+ async replyCompactCard(messageId, view, chatId) {
2395
+ const res = await this.client.im.message.reply({
2396
+ path: { message_id: messageId },
2397
+ data: {
2398
+ content: JSON.stringify(this.buildCompactProgressCard(view, chatId)),
2399
+ msg_type: "interactive",
2400
+ },
2401
+ });
2402
+ return res?.data?.message_id || res?.message_id;
2403
+ }
2404
+ async patchCompactCard(messageId, view, chatId) {
2405
+ await this.client.im.message.patch({
2406
+ path: { message_id: messageId },
2407
+ data: { content: JSON.stringify(this.buildCompactProgressCard(view, chatId)) },
2408
+ });
2409
+ }
2038
2410
  async sendTextMessage(chatId, text) {
2039
2411
  try {
2040
2412
  const res = await this.client.im.message.create({
@@ -2600,14 +2972,115 @@ export class FeishuBot {
2600
2972
  /**
2601
2973
  * Handle /compact command: compress session context.
2602
2974
  */
2975
+ /**
2976
+ * Compact a session with a fallback chain:
2977
+ * 1. OpenClaw's native (LLM-summary) compaction — best semantics.
2978
+ * 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
2979
+ * the transcript file to drop tool calls/results while keeping the
2980
+ * conversation. This needs no model, so it works no matter how large the
2981
+ * session is (the native path itself fails to fit an oversized session
2982
+ * into the model). A backup is always written before any file change.
2983
+ * Returns a structured result describing what happened.
2984
+ */
2985
+ async compactWithFallback(sessionKey, onPhase) {
2986
+ // 1. Native compaction first.
2987
+ let nativeReason;
2988
+ try {
2989
+ const res = await this.openclawClient.compactSession(sessionKey);
2990
+ if (res?.compacted === true)
2991
+ return { compacted: true, method: "native" };
2992
+ nativeReason = typeof res?.reason === "string" ? res.reason : undefined;
2993
+ }
2994
+ catch (err) {
2995
+ nativeReason = err.message;
2996
+ }
2997
+ // 2. Fall back to tool-trim on the transcript file. Tell the caller so a
2998
+ // progress card can flip from "compacting" to "fast-trim" (this is the
2999
+ // slow→still-working transition the user most needs to see).
3000
+ onPhase?.("tool-trim");
3001
+ let sessionId;
3002
+ try {
3003
+ const info = await this.openclawClient.getSessionInfo(sessionKey);
3004
+ sessionId = info?.session?.sessionId;
3005
+ }
3006
+ catch {
3007
+ // ignore; handled below
3008
+ }
3009
+ if (!sessionId) {
3010
+ return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
3011
+ }
3012
+ const filePath = resolveSessionFilePath(sessionKey, sessionId);
3013
+ if (!filePath) {
3014
+ return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
3015
+ }
3016
+ const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
3017
+ if (!trim.ok) {
3018
+ return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
3019
+ }
3020
+ const before = trim.bytesBefore || 0;
3021
+ const after = trim.bytesAfter || 0;
3022
+ if (before > 0 && after >= before) {
3023
+ // Nothing meaningful trimmed.
3024
+ return { compacted: false, method: "none", reason: trim.reason || "no tool content to trim" };
3025
+ }
3026
+ const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
3027
+ const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
3028
+ return {
3029
+ compacted: true,
3030
+ method: "tool-trim",
3031
+ detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
3032
+ };
3033
+ }
2603
3034
  async handleCompactCommand(chatId, messageId) {
2604
3035
  const sessionKey = this.getSessionKey(chatId);
3036
+ const en = this.isEn(chatId);
3037
+ // Large sessions can take tens of seconds to compact (native compaction may
3038
+ // even time out before the tool-trim fallback kicks in). Show a ticking
3039
+ // "compacting…" card so the user can SEE it is working instead of staring at
3040
+ // nothing and assuming nothing is happening. The card is created lazily, so
3041
+ // a fast compaction that finishes quickly never flashes a card at all.
3042
+ const progress = new CompactProgressController({
3043
+ create: (view) => this.sendOrdered(chatId, () => this.replyCompactCard(messageId, view, chatId)),
3044
+ edit: (id, view) => this.sendOrdered(chatId, () => this.patchCompactCard(id, view, chatId)),
3045
+ warn: (msg, err) => console.warn(`[${this.config.name}] ${msg}`, err instanceof Error ? err.message : err),
3046
+ }, { locale: en ? "en" : "zh" });
3047
+ progress.start();
2605
3048
  try {
2606
- await this.openclawClient.compactSession(sessionKey);
2607
- await this.replyMessage(messageId, `✅ Session 已压缩`);
3049
+ const r = await this.compactWithFallback(sessionKey, (phase) => {
3050
+ if (phase === "tool-trim")
3051
+ void progress.toToolTrim().catch(() => { });
3052
+ });
3053
+ if (r.compacted) {
3054
+ // If a card was shown, patch it in place; otherwise (fast finish, no card)
3055
+ // fall back to a normal reply so the user still gets confirmation.
3056
+ const detail = r.method === "tool-trim" && r.detail
3057
+ ? (en ? `tool-trim ${r.detail}` : `删工具调用 ${r.detail}`)
3058
+ : (en ? "native" : "原生压缩");
3059
+ await progress.done(detail);
3060
+ if (!progress.id) {
3061
+ await this.replyMessage(messageId, r.method === "tool-trim"
3062
+ ? (en ? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})` : `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`)
3063
+ : (en ? `✅ Session compacted` : `✅ Session 已压缩`));
3064
+ }
3065
+ }
3066
+ else {
3067
+ const reason = r.reason || (en ? "nothing to compact" : "无需压缩");
3068
+ await progress.noop(reason);
3069
+ if (!progress.id) {
3070
+ await this.replyMessage(messageId, en
3071
+ ? `ℹ️ Session not compacted (${reason})`
3072
+ : `ℹ️ Session 未压缩(${reason})`);
3073
+ }
3074
+ }
2608
3075
  }
2609
3076
  catch (err) {
2610
- await this.replyMessage(messageId, `❌ 压缩失败: ${err.message}`);
3077
+ const reason = err.message;
3078
+ await progress.fail(reason);
3079
+ if (!progress.id) {
3080
+ await this.replyMessage(messageId, en
3081
+ ? `❌ Compact failed: ${reason}`
3082
+ : `❌ 压缩失败: ${reason}`);
3083
+ }
2611
3084
  }
2612
3085
  }
2613
3086
  /**
@@ -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,