openclaw-lark-multi-agent 1.4.3 → 1.4.5

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.
@@ -9,7 +9,6 @@ import { basename, extname, join, resolve } from "path";
9
9
  import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
10
10
  import { buildFeishuCardElements } from "./markdown.js";
11
11
  import { discussionManager } from "./discussion-manager.js";
12
- import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
13
12
  const require = createRequire(import.meta.url);
14
13
  const LMA_VERSION = require("../package.json").version;
15
14
  const MAX_BOT_STREAK = 10;
@@ -42,9 +41,13 @@ function steerInjectionPrefix(en) {
42
41
  || "[New message inserted by the user mid-task; please handle it together with the current task]";
43
42
  return en ? enText : zh;
44
43
  }
45
- // How many of the most-recent tool calls (and their results) tool-trim keeps
46
- // intact — recent tool calls may still be relevant to the agent's next step.
47
- function toolTrimKeepRecent() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_TOOLTRIM_KEEP_RECENT || 3); }
44
+ // Emergency transcript trim used only when semantic compaction cannot run.
45
+ // The Gateway owns the transcript store (JSONL before 2026.8, SQLite after it),
46
+ // so LMA must use sessions.compact instead of mutating storage directly.
47
+ function compactFallbackMaxLines() {
48
+ const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 200);
49
+ return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 200;
50
+ }
48
51
  // After a reply is delivered, if context usage reaches this percent, send the
49
52
  // user a one-time alert to compact. 0 disables the alert.
50
53
  function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
@@ -90,14 +93,26 @@ export class FeishuBot {
90
93
  sendQueue = new Map();
91
94
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
92
95
  delayedFailureTimers = new Map();
96
+ /** Ephemeral model footer overrides used by final-message wrappers. Keeping
97
+ * replyMessage/sendMessage two-argument APIs avoids contaminating ordinary
98
+ * command/error/card sends with model attribution. */
99
+ replyModelFooters = new Map();
100
+ sendModelFooters = new Map();
93
101
  /** Per-chat durable outbox retry timer. */
94
102
  deliveryRetryTimers = new Map();
95
103
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
96
104
  lastRealDeliveryAt = new Map();
97
105
  /** Per-chat: whether we've already sent the high-context alert this cycle. */
98
106
  contextAlerted = new Map();
99
- /** Active chatSend trigger target so final replies and proactive session.message share one delivery key. */
107
+ /** Active pre-admission target for the current chat operation. */
100
108
  activeDeliveryTargets = new Map();
109
+ /** Stable targets for concurrent runs in one chat. A steer admission can
110
+ * become an ordinary run while another run remains active. */
111
+ deliveryTargetsByRun = new Map();
112
+ /** Provisional native-steer correlations keyed by durable message row. If the
113
+ * message falls back to a normal run, cancel these before submission so a
114
+ * later identical steer cannot consume the stale observer/correlation. */
115
+ pendingSteerCleanups = new Map();
101
116
  adminOpenId;
102
117
  locale;
103
118
  configPath;
@@ -220,7 +235,10 @@ export class FeishuBot {
220
235
  }
221
236
  }
222
237
  catch (err) {
238
+ // Session/model setup is authoritative. Propagate policy/capability errors
239
+ // so the queue can notify the user and never continue on the wrong model.
223
240
  console.warn(`[${this.config.name}] ensureSession error:`, err.message);
241
+ throw err;
224
242
  }
225
243
  this.initializedSessions.add(sessionKey);
226
244
  // Subscribe to session events (proactive messages + tool calls)
@@ -232,19 +250,21 @@ export class FeishuBot {
232
250
  const parsed = this.extractBridgeAttachments(text);
233
251
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
234
252
  this.cancelDelayedFailure(chatId);
235
- const activeTarget = this.activeDeliveryTargets.get(chatId);
236
- let finalStatusMeta;
253
+ const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) : undefined;
254
+ const candidateTarget = this.activeDeliveryTargets.get(chatId);
255
+ const activeTarget = runTarget || (candidateTarget && (!candidateTarget.runId || !meta?.runId || candidateTarget.runId === meta.runId)
256
+ ? candidateTarget
257
+ : undefined);
237
258
  try {
259
+ await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model);
238
260
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
239
- finalStatusMeta = await activeTarget?.liveStatus?.prepareTerminalDelivery(false);
240
- }
241
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, finalStatusMeta);
242
- if (!finalStatusMeta && sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
243
261
  await activeTarget?.liveStatus?.complete().catch(() => { });
262
+ if (meta?.runId && runTarget)
263
+ this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
244
264
  }
245
265
  }
246
266
  catch (err) {
247
- if (!finalStatusMeta && sourceType !== "verbose_transcript")
267
+ if (sourceType !== "verbose_transcript")
248
268
  await activeTarget?.liveStatus?.fail().catch(() => { });
249
269
  throw err;
250
270
  }
@@ -922,44 +942,81 @@ export class FeishuBot {
922
942
  // Wrap the message so the agent knows it is a NEW user message inserted
923
943
  // mid-task. The live-status line and reactions still key off the original.
924
944
  const steerWrapped = `${steerInjectionPrefix(this.isEn(chatId))}\n${cleanText}`;
945
+ // Register the consumption observer before submitting so a fast
946
+ // session.message cannot race past the Feishu reaction transition.
947
+ await this.addReaction(messageId, "Typing").catch(() => { });
948
+ pending.push({ messageId, emoji: "Typing", rowId: insertedId });
949
+ this.pendingAckMessages.set(chatId, pending);
950
+ const norm = cleanText.replace(/\s+/g, " ").trim();
951
+ const stopWatchingConsumption = this.openclawClient.onSteerConsumed(sessionKeyForSteer, steerWrapped, (consumedText) => {
952
+ const cn = (consumedText || "").replace(/\s+/g, " ").trim();
953
+ if (cn !== norm && !cn.includes(norm))
954
+ return false;
955
+ // Only transcript commitment/consumption transfers durable custody.
956
+ // Until this point the local pending trigger remains the fallback for
957
+ // detached dispatch rejection or an admission that never consumed.
958
+ if (insertedId > 0) {
959
+ this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
960
+ this.pendingSteerCleanups.delete(insertedId);
961
+ }
962
+ void this.removeReaction(messageId, "Typing").catch(() => { });
963
+ void this.addReaction(messageId, "Get").catch(() => { });
964
+ const acks = this.pendingAckMessages.get(chatId) || [];
965
+ for (const a of acks)
966
+ if (a.messageId === messageId)
967
+ a.emoji = "Get";
968
+ this.pendingAckMessages.set(chatId, acks);
969
+ return true;
970
+ });
971
+ const previousActiveTarget = this.activeDeliveryTargets.get(chatId);
972
+ // Install the new Feishu delivery target before admission. OpenClaw may
973
+ // race from "old run still active" to "dispatch a new run" while
974
+ // handling queueMode=steer, and that new run can emit events before the
975
+ // RPC response reaches LMA. Either outcome now routes to this message.
976
+ const releaseSteerDeliveryTarget = this.setActiveDeliveryTarget(chatId, insertedId, messageId);
977
+ const provisionalTarget = this.activeDeliveryTargets.get(chatId);
925
978
  const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
926
979
  if (steer.status === "steered") {
927
- // Accepted into the active run's steer queue, but NOT yet consumed by
928
- // the model. Show "Typing" (awaiting insertion) now; flip to "Get" only
929
- // when the model actually consumes it at a tool-call boundary (a
930
- // matching sessionUser event), which also renders it on the live-status
931
- // card in correct time order.
932
- await this.addReaction(messageId, "Typing").catch(() => { });
933
- pending.push({ messageId, emoji: "Typing", rowId: insertedId });
934
- this.pendingAckMessages.set(chatId, pending);
935
- const norm = cleanText.replace(/\s+/g, " ").trim();
936
- this.openclawClient.onSteerConsumed(sessionKeyForSteer, (consumedText) => {
937
- const cn = (consumedText || "").replace(/\s+/g, " ").trim();
938
- if (cn !== norm && !cn.includes(norm))
939
- return false; // not ours; keep waiting
940
- // Flip Typing -> Get now that the model has actually read it.
941
- void this.removeReaction(messageId, "Typing").catch(() => { });
942
- void this.addReaction(messageId, "Get").catch(() => { });
943
- const acks = this.pendingAckMessages.get(chatId) || [];
944
- for (const a of acks)
945
- if (a.messageId === messageId)
946
- a.emoji = "Get";
947
- this.pendingAckMessages.set(chatId, acks);
948
- return true; // one-shot: matched, remove this callback
949
- });
950
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, STEERED (awaiting consumption): "${cleanText.substring(0, 50)}..."`);
951
- // The message is already in DB with a pending trigger. Clear that
952
- // trigger so it is NOT re-run as a separate follow-up batch after the
953
- // current run ends — it has already been injected into this run.
954
- if (insertedId > 0)
955
- this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
980
+ if (insertedId > 0) {
981
+ this.pendingSteerCleanups.set(insertedId, () => {
982
+ stopWatchingConsumption();
983
+ if ("cancelPending" in steer)
984
+ steer.cancelPending?.();
985
+ });
986
+ }
987
+ if (provisionalTarget && steer.runId) {
988
+ provisionalTarget.runId = steer.runId;
989
+ this.bindRunDeliveryTarget(chatId, steer.runId, provisionalTarget);
990
+ this.releaseRunDeliveryTargetAfter(chatId, steer.runId, 30 * 60_000);
991
+ }
992
+ // A steer admission may become an ordinary new run. Keep its run-bound
993
+ // reply target long enough for a real tool-heavy run; stale targets are
994
+ // harmless because later runIds no longer match.
995
+ releaseSteerDeliveryTarget(30 * 60_000);
996
+ // RPC acknowledgment is provisional. Keep the durable pending trigger
997
+ // until session.message confirms consumption; otherwise the normal
998
+ // queue drains it after the current run.
999
+ console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, steer accepted (awaiting transcript consumption): "${cleanText.substring(0, 50)}..."`);
956
1000
  return;
957
1001
  }
958
- // Could not steer (no active run raced to idle, rejected, or plugin
959
- // unavailable): fall back to the original behavior — queue and wait.
960
- await this.addReaction(messageId, "Typing").catch(() => { });
961
- pending.push({ messageId, emoji: "Typing", rowId: insertedId });
962
- this.pendingAckMessages.set(chatId, pending);
1002
+ // Native steer was not accepted. Restore the previous run's routing
1003
+ // target and keep the already-created trigger/reaction for normal drain.
1004
+ stopWatchingConsumption();
1005
+ if ("cancelPending" in steer)
1006
+ steer.cancelPending?.();
1007
+ const currentTarget = this.activeDeliveryTargets.get(chatId);
1008
+ if (currentTarget && currentTarget.token === provisionalTarget?.token) {
1009
+ if (currentTarget.timer)
1010
+ clearTimeout(currentTarget.timer);
1011
+ this.activeDeliveryTargets.delete(chatId);
1012
+ if (previousActiveTarget) {
1013
+ const releaseRestoredTarget = this.setActiveDeliveryTarget(chatId, previousActiveTarget.triggerId, previousActiveTarget.messageId);
1014
+ const restored = this.activeDeliveryTargets.get(chatId);
1015
+ if (restored)
1016
+ restored.liveStatus = previousActiveTarget.liveStatus;
1017
+ releaseRestoredTarget();
1018
+ }
1019
+ }
963
1020
  console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
964
1021
  return; // Message is in DB, will be picked up when agent finishes
965
1022
  }
@@ -1189,6 +1246,16 @@ export class FeishuBot {
1189
1246
  const runStartStopEpoch = this.stopEpoch.get(chatId) || 0;
1190
1247
  const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
1191
1248
  const mergedTriggerIds = mergedTriggers.map((m) => m.id || 0).filter(Boolean);
1249
+ // These rows are now taking the ordinary queue path. Remove any stale
1250
+ // provisional native-steer correlation before chat.send so a later
1251
+ // identical steer cannot acknowledge the wrong Feishu message.
1252
+ for (const id of mergedTriggerIds) {
1253
+ const cleanup = this.pendingSteerCleanups.get(id);
1254
+ if (cleanup) {
1255
+ cleanup();
1256
+ this.pendingSteerCleanups.delete(id);
1257
+ }
1258
+ }
1192
1259
  const droppedTriggerIds = droppedMergedTriggers.map((m) => m.id || 0).filter(Boolean);
1193
1260
  const mergedTriggerIdSet = new Set(mergedTriggerIds);
1194
1261
  const completedTriggerIdSet = new Set([...droppedTriggerIds, ...mergedTriggerIds]);
@@ -1237,7 +1304,28 @@ export class FeishuBot {
1237
1304
  }
1238
1305
  const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
1239
1306
  const queueStartedAt = Date.now();
1240
- const sessionKey = await this.ensureSession(chatId);
1307
+ let sessionKey;
1308
+ try {
1309
+ sessionKey = await this.ensureSession(chatId);
1310
+ }
1311
+ catch (err) {
1312
+ // Model policy/capability failures happen before chat.send. Clear the
1313
+ // busy marker, report the actionable error, and retire only this batch
1314
+ // so the chat cannot remain wedged indefinitely.
1315
+ this.busyChats.set(chatId, 0);
1316
+ const errorText = this.formatUserVisibleError(err);
1317
+ if (lastHuman.messageId) {
1318
+ await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:session-setup-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:session-setup-error`).catch(() => { });
1319
+ }
1320
+ for (const id of mergedTriggerIds)
1321
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
1322
+ this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:session-setup-failed`);
1323
+ if (mergedTriggerIds.length > 0)
1324
+ this.store.markSynced(this.config.name, chatId, Math.max(...mergedTriggerIds));
1325
+ const pendingAcks = this.pendingAckMessages.get(chatId) || [];
1326
+ this.pendingAckMessages.set(chatId, pendingAcks.filter((ack) => !completedTriggerIdSet.has(ack.rowId)));
1327
+ break;
1328
+ }
1241
1329
  console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length}, dropped=${droppedTriggerIds.length})`);
1242
1330
  // Update reactions: queued messages → sent (GET/了解)
1243
1331
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
@@ -1303,6 +1391,12 @@ export class FeishuBot {
1303
1391
  },
1304
1392
  onSubmitted: (runId) => {
1305
1393
  runAcceptedByOpenClaw = true;
1394
+ const target = this.activeDeliveryTargets.get(chatId);
1395
+ if (target?.triggerId === triggerId) {
1396
+ target.runId = runId;
1397
+ this.bindRunDeliveryTarget(chatId, runId, target);
1398
+ this.releaseRunDeliveryTargetAfter(chatId, runId, 30 * 60_000);
1399
+ }
1306
1400
  // Refine state for logs/idempotent sync rows when OpenClaw returns
1307
1401
  // a runId. markSubmittedBatch is idempotent due to local flags and
1308
1402
  // INSERT OR IGNORE in message_sync.
@@ -1435,21 +1529,12 @@ export class FeishuBot {
1435
1529
  await liveStatus?.complete().catch(() => { });
1436
1530
  }
1437
1531
  else {
1438
- let finalStatusMeta;
1439
1532
  try {
1440
- // If a live-status card exists, freeze it and persist enough
1441
- // metadata in the outbox to turn THIS SAME card into the final
1442
- // answer. Fast replies / disabled status / create failures have no
1443
- // card and transparently use the existing new-message path.
1444
- // Text answers become the card body. Attachment-only results still
1445
- // freeze the card so the outbox can durably collapse it to a done
1446
- // summary (including disabled-but-existing cards).
1447
- if ((shouldReply && visibleReply.trim()) || hasAttachments) {
1448
- finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
1449
- }
1450
- await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, finalStatusMeta);
1451
- if (!finalStatusMeta)
1452
- await liveStatus?.complete();
1533
+ // Keep live status and final answer as separate messages. The final
1534
+ // answer is created at completion time so it remains at the bottom
1535
+ // even when the user inserted more messages during the run.
1536
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model);
1537
+ await liveStatus?.complete();
1453
1538
  // Mark every merged trigger as delivered so retries/restarts do
1454
1539
  // not re-process any of them.
1455
1540
  for (const id of mergedTriggerIds)
@@ -1461,11 +1546,7 @@ export class FeishuBot {
1461
1546
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1462
1547
  // failure. Do not fall through to the generic provider-error path;
1463
1548
  // that creates a second misleading "bot did not complete" message.
1464
- // If the card was not frozen for merged final delivery, retain the
1465
- // normal interrupted status. A prepared card has already been
1466
- // patched/fallen back by the durable dispatcher.
1467
- if (!finalStatusMeta)
1468
- await liveStatus?.fail().catch(() => { });
1549
+ await liveStatus?.fail().catch(() => { });
1469
1550
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1470
1551
  }
1471
1552
  }
@@ -1714,13 +1795,19 @@ export class FeishuBot {
1714
1795
  }
1715
1796
  return s;
1716
1797
  }
1717
- buildMarkdownCard(text) {
1798
+ buildMarkdownCard(text, model) {
1799
+ const elements = buildFeishuCardElements(text);
1800
+ if (model?.trim()) {
1801
+ elements.push({ tag: "hr" });
1802
+ elements.push({
1803
+ tag: "markdown",
1804
+ content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
1805
+ });
1806
+ }
1718
1807
  return {
1719
1808
  schema: "2.0",
1720
1809
  config: { wide_screen_mode: true },
1721
- body: {
1722
- elements: buildFeishuCardElements(text),
1723
- },
1810
+ body: { elements },
1724
1811
  };
1725
1812
  }
1726
1813
  deliveryOwnsVisiblePayload(item) {
@@ -1738,33 +1825,12 @@ export class FeishuBot {
1738
1825
  }
1739
1826
  completionSummary(meta, emoji = "✅") {
1740
1827
  const en = meta.locale === "en";
1741
- const core = en
1828
+ return en
1742
1829
  ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1743
1830
  : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1744
- return meta.model ? `${core} · 🧠 ${this.escapeCardText(meta.model)}` : core;
1745
1831
  }
1746
- /** Final answer card used to replace the running live-status card in place. */
1747
- buildFinalAnswerCard(text, meta) {
1748
- const elements = buildFeishuCardElements(text);
1749
- elements.push({ tag: "hr" });
1750
- elements.push({
1751
- tag: "markdown",
1752
- content: `<font color='grey'>${this.completionSummary(meta)}</font>`,
1753
- });
1754
- return {
1755
- schema: "2.0",
1756
- config: { update_multi: true, width_mode: "fill" },
1757
- body: { elements },
1758
- };
1759
- }
1760
- async patchFinalAnswerCard(messageId, text, meta, _chatId) {
1761
- await this.client.im.message.patch({
1762
- path: { message_id: messageId },
1763
- data: { content: JSON.stringify(this.buildFinalAnswerCard(text, meta)) },
1764
- });
1765
- }
1766
- /** Best-effort cleanup when a final-card patch was too large/failed and the
1767
- * answer had to be delivered as a separate message. */
1832
+ /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
1833
+ * outbox entries that once targeted the live-status card. */
1768
1834
  async patchLiveStatusDoneSummary(messageId, meta, chatId) {
1769
1835
  const view = {
1770
1836
  title: meta.locale === "en" ? `✅ ${this.config.name} done` : `✅ ${this.config.name} 已完成`,
@@ -2039,18 +2105,48 @@ export class FeishuBot {
2039
2105
  clearTimeout(timer);
2040
2106
  this.delayedFailureTimers.delete(chatId);
2041
2107
  }
2108
+ deliveryRunTargetKey(chatId, runId) {
2109
+ return `${chatId}\u0000${runId}`;
2110
+ }
2111
+ bindRunDeliveryTarget(chatId, runId, target) {
2112
+ if (!runId)
2113
+ return;
2114
+ const key = this.deliveryRunTargetKey(chatId, runId);
2115
+ const previous = this.deliveryTargetsByRun.get(key);
2116
+ if (previous?.timer)
2117
+ clearTimeout(previous.timer);
2118
+ this.deliveryTargetsByRun.set(key, {
2119
+ triggerId: target.triggerId,
2120
+ messageId: target.messageId,
2121
+ liveStatus: target.liveStatus,
2122
+ });
2123
+ }
2124
+ releaseRunDeliveryTargetAfter(chatId, runId, delayMs) {
2125
+ const key = this.deliveryRunTargetKey(chatId, runId);
2126
+ const target = this.deliveryTargetsByRun.get(key);
2127
+ if (!target)
2128
+ return;
2129
+ if (target.timer)
2130
+ clearTimeout(target.timer);
2131
+ const timer = setTimeout(() => {
2132
+ if (this.deliveryTargetsByRun.get(key)?.timer === timer)
2133
+ this.deliveryTargetsByRun.delete(key);
2134
+ }, delayMs);
2135
+ timer.unref?.();
2136
+ target.timer = timer;
2137
+ }
2042
2138
  setActiveDeliveryTarget(chatId, triggerId, messageId) {
2043
2139
  const existing = this.activeDeliveryTargets.get(chatId);
2044
2140
  if (existing?.timer)
2045
2141
  clearTimeout(existing.timer);
2046
2142
  const token = Symbol(`${chatId}:${triggerId}`);
2047
2143
  this.activeDeliveryTargets.set(chatId, { triggerId, messageId, token });
2048
- return () => {
2144
+ return (delayMs = 60_000) => {
2049
2145
  const timer = setTimeout(() => {
2050
2146
  const current = this.activeDeliveryTargets.get(chatId);
2051
2147
  if (current?.token === token)
2052
2148
  this.activeDeliveryTargets.delete(chatId);
2053
- }, 60_000);
2149
+ }, delayMs);
2054
2150
  const current = this.activeDeliveryTargets.get(chatId);
2055
2151
  if (current?.token === token)
2056
2152
  current.timer = timer;
@@ -2094,58 +2190,27 @@ export class FeishuBot {
2094
2190
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2095
2191
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
2096
2192
  }
2097
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, liveStatusFinal) {
2193
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
2098
2194
  if (!text.trim() && attachments.length === 0)
2099
2195
  return;
2100
2196
  const attachmentsJson = JSON.stringify(attachments);
2101
2197
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
2102
2198
  const contentHash = this.stableHash(normalizedPayload);
2103
2199
  const finalDeliveryKey = deliveryKey || sourceId;
2104
- const enqueueDurableStatusCleanup = async (meta, suffix) => {
2105
- const cleanupId = this.store.enqueueDelivery({
2106
- sessionKey: this.deliverySessionKey(chatId),
2107
- chatId,
2108
- botName: this.config.name,
2109
- sourceType: `${sourceType}_status_cleanup`,
2110
- sourceId: `${sourceId}:status-cleanup:${suffix}:${meta.messageId}`,
2111
- deliveryKey: `${finalDeliveryKey}:status-cleanup:${suffix}:${meta.messageId}`,
2112
- contentHash: "",
2113
- content: "",
2114
- attachmentsJson: "[]",
2115
- replyToMessageId: replyToMessageId || "",
2116
- deliveryMode: "patch_live_status",
2117
- targetMessageId: meta.messageId,
2118
- deliveryMetaJson: JSON.stringify(meta),
2119
- textDelivered: true,
2120
- cleanupPending: true,
2121
- });
2122
- if (cleanupId)
2123
- await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2124
- };
2125
- const finishDedupedStatus = async () => {
2126
- if (!liveStatusFinal)
2127
- return;
2128
- await enqueueDurableStatusCleanup(liveStatusFinal, "content-dedupe");
2129
- };
2200
+ const deliveryMetaJson = finalReplyModel?.trim()
2201
+ ? JSON.stringify({ model: finalReplyModel.trim() })
2202
+ : "{}";
2130
2203
  if (sourceType === "verbose_transcript") {
2131
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"])) {
2132
- await finishDedupedStatus();
2204
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
2133
2205
  return;
2134
- }
2135
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"])) {
2136
- await finishDedupedStatus();
2206
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2137
2207
  return;
2138
- }
2139
2208
  }
2140
2209
  else if (!deliveryKey) {
2141
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType])) {
2142
- await finishDedupedStatus();
2210
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2143
2211
  return;
2144
- }
2145
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType])) {
2146
- await finishDedupedStatus();
2212
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
2147
2213
  return;
2148
- }
2149
2214
  }
2150
2215
  const deliveryId = this.store.enqueueDelivery({
2151
2216
  sessionKey: this.deliverySessionKey(chatId),
@@ -2158,9 +2223,9 @@ export class FeishuBot {
2158
2223
  content: text,
2159
2224
  attachmentsJson,
2160
2225
  replyToMessageId: replyToMessageId || "",
2161
- deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2162
- targetMessageId: liveStatusFinal?.messageId || "",
2163
- deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2226
+ deliveryMode: "send",
2227
+ targetMessageId: "",
2228
+ deliveryMetaJson,
2164
2229
  });
2165
2230
  if (deliveryId === null) {
2166
2231
  // Another path already owns this stable logical delivery (commonly the
@@ -2182,9 +2247,9 @@ export class FeishuBot {
2182
2247
  sourceType: `${sourceType}_recovery`, sourceId: `${sourceId}:recovery`,
2183
2248
  deliveryKey: recoveryKey, contentHash, content: text, attachmentsJson: "[]",
2184
2249
  replyToMessageId: replyToMessageId || "",
2185
- deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2186
- targetMessageId: liveStatusFinal?.messageId || "",
2187
- deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2250
+ deliveryMode: "send",
2251
+ targetMessageId: "",
2252
+ deliveryMetaJson,
2188
2253
  });
2189
2254
  }
2190
2255
  }
@@ -2199,11 +2264,9 @@ export class FeishuBot {
2199
2264
  sourceType: "assistant_visible_authoritative", sourceId: `${sourceId}:authoritative`,
2200
2265
  deliveryKey: correctionKey, contentHash, content: text, attachmentsJson: "[]",
2201
2266
  replyToMessageId: replyToMessageId || "",
2202
- deliveryMode: liveStatusFinal || existing.targetMessageId ? "patch_live_status" : "send",
2203
- targetMessageId: liveStatusFinal?.messageId || existing.targetMessageId,
2204
- deliveryMetaJson: liveStatusFinal?.messageId
2205
- ? JSON.stringify(liveStatusFinal)
2206
- : existing.deliveryMetaJson,
2267
+ deliveryMode: "send",
2268
+ targetMessageId: "",
2269
+ deliveryMetaJson,
2207
2270
  });
2208
2271
  }
2209
2272
  // A competing final may add attachments that the first owner did not see.
@@ -2265,34 +2328,6 @@ export class FeishuBot {
2265
2328
  }
2266
2329
  // Dispatch recovery/correction/supplement rows together in creation order.
2267
2330
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2268
- // A correction/recovery row can intentionally patch the newly-frozen card.
2269
- // Never enqueue a compact cleanup for that same card afterwards: it would
2270
- // overwrite the authoritative answer that was just patched into place.
2271
- const competingRows = this.store.listDeliveriesByKeyPrefix(this.config.name, chatId, `${finalDeliveryKey}:`);
2272
- const finalCardOwnsVisiblePayload = Boolean(liveStatusFinal) && [existing, ...competingRows]
2273
- .some((row) => row?.targetMessageId === liveStatusFinal.messageId && this.deliveryOwnsVisiblePayload(row));
2274
- if (liveStatusFinal && existing?.targetMessageId !== liveStatusFinal.messageId && !finalCardOwnsVisiblePayload) {
2275
- const cleanupJson = JSON.stringify(liveStatusFinal);
2276
- const cleanupId = this.store.enqueueDelivery({
2277
- sessionKey: this.deliverySessionKey(chatId),
2278
- chatId,
2279
- botName: this.config.name,
2280
- sourceType: `${sourceType}_status_cleanup`,
2281
- sourceId: `${sourceId}:status-cleanup:${liveStatusFinal.messageId}`,
2282
- deliveryKey: `${finalDeliveryKey}:status-cleanup:${liveStatusFinal.messageId}`,
2283
- contentHash: "",
2284
- content: "",
2285
- attachmentsJson: "[]",
2286
- replyToMessageId: replyToMessageId || "",
2287
- deliveryMode: "patch_live_status",
2288
- targetMessageId: liveStatusFinal.messageId,
2289
- deliveryMetaJson: cleanupJson,
2290
- textDelivered: true,
2291
- cleanupPending: true,
2292
- });
2293
- if (cleanupId)
2294
- await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2295
- }
2296
2331
  return;
2297
2332
  }
2298
2333
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
@@ -2326,49 +2361,39 @@ export class FeishuBot {
2326
2361
  let failureStage = "text";
2327
2362
  try {
2328
2363
  const attachments = JSON.parse(item.attachmentsJson || "[]");
2329
- // Text first. A patch error is ambiguous: Feishu may have applied the
2330
- // patch but timed out returning the response. Do NOT immediately create
2331
- // a second visible answer. Retry the same idempotent patch; only after
2332
- // the retry budget is exhausted do we fall back to a new message.
2364
+ // Final text is always sent as a NEW message. Never patch it into the
2365
+ // older live-status card: user messages inserted during the run must
2366
+ // stay above the eventual final answer, not below it.
2333
2367
  if (item.content.trim() && !item.textDelivered) {
2334
2368
  const replyTarget = item.replyToMessageId || replyToMessageId;
2335
- const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "verbose_transcript" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
2336
- if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2369
+ const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
2370
+ || item.sourceType === "verbose_transcript"
2371
+ || item.sourceType === "provider_error"
2372
+ || item.sourceType === "delayed_error");
2373
+ let finalModel;
2374
+ try {
2375
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2376
+ finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
2377
+ }
2378
+ catch { /* legacy/malformed metadata: send without model footer */ }
2379
+ if (shouldReplyToSource) {
2337
2380
  try {
2338
- const meta = JSON.parse(item.deliveryMetaJson || "{}");
2339
- await this.patchFinalAnswerCard(item.targetMessageId, item.content, meta, chatId);
2340
- this.store.markDeliveryTextDelivered(item.id);
2381
+ await this.replyFinalMessage(replyTarget, item.content, finalModel);
2341
2382
  }
2342
- catch (patchErr) {
2343
- if (item.attempts + 1 < DELIVERY_MAX_ATTEMPTS)
2344
- throw patchErr;
2345
- console.warn(`[${this.config.name}] final-card patch exhausted; falling back to new message:`, this.errorSummary(patchErr));
2346
- if (shouldReplyToSource) {
2347
- try {
2348
- await this.replyMessage(replyTarget, item.content);
2349
- }
2350
- catch {
2351
- await this.sendMessage(chatId, item.content);
2352
- }
2353
- }
2354
- else {
2355
- await this.sendMessage(chatId, item.content);
2356
- }
2357
- this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2383
+ catch {
2384
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2358
2385
  }
2359
2386
  }
2360
2387
  else {
2361
- if (shouldReplyToSource) {
2362
- try {
2363
- await this.replyMessage(replyTarget, item.content);
2364
- }
2365
- catch {
2366
- await this.sendMessage(chatId, item.content);
2367
- }
2368
- }
2369
- else {
2370
- await this.sendMessage(chatId, item.content);
2371
- }
2388
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2389
+ }
2390
+ // Rows created by v1.4.3 may still target a live-status card. Preserve
2391
+ // restart compatibility: send their text as a new message, then
2392
+ // collapse the old status card in the cleanup stage below.
2393
+ if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2394
+ this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2395
+ }
2396
+ else {
2372
2397
  this.store.markDeliveryTextDelivered(item.id);
2373
2398
  }
2374
2399
  }
@@ -2589,7 +2614,6 @@ export class FeishuBot {
2589
2614
  const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
2590
2615
  displayReply = `${displayReply}\n\n${roundMarker}`;
2591
2616
  }
2592
- let finalStatusMeta;
2593
2617
  if (isVisible || parsedReply.attachments.length > 0) {
2594
2618
  const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
2595
2619
  .filter(Boolean)
@@ -2603,14 +2627,10 @@ export class FeishuBot {
2603
2627
  timestamp: Date.now(),
2604
2628
  });
2605
2629
  try {
2606
- if (isVisible || parsedReply.attachments.length > 0) {
2607
- finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
2608
- }
2609
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, finalStatusMeta);
2630
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
2610
2631
  }
2611
2632
  catch (err) {
2612
- if (!finalStatusMeta)
2613
- await liveStatus?.fail().catch(() => { });
2633
+ await liveStatus?.fail().catch(() => { });
2614
2634
  throw err;
2615
2635
  }
2616
2636
  }
@@ -2619,7 +2639,7 @@ export class FeishuBot {
2619
2639
  if (!isVisible && parsedReply.attachments.length === 0) {
2620
2640
  await liveStatus?.noReply().catch(() => { });
2621
2641
  }
2622
- else if (!finalStatusMeta) {
2642
+ else {
2623
2643
  await liveStatus?.complete().catch(() => { });
2624
2644
  }
2625
2645
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
@@ -2634,7 +2654,6 @@ export class FeishuBot {
2634
2654
  const summary = this.completionSummary({
2635
2655
  toolCalls: view.toolCalls,
2636
2656
  elapsed: view.elapsed,
2637
- model: view.model,
2638
2657
  locale: en ? "en" : "zh",
2639
2658
  }, statusEmoji);
2640
2659
  return {
@@ -2667,12 +2686,10 @@ export class FeishuBot {
2667
2686
  elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
2668
2687
  }
2669
2688
  elements.push({ tag: "hr" });
2670
- // Footer: elapsed time + model name.
2671
- const footerBits = [];
2672
- footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
2673
- if (view.model)
2674
- footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
2675
- elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2689
+ // Running/failed status owns timing; model attribution belongs to the final
2690
+ // reply card so the two messages have clear, non-duplicated responsibilities.
2691
+ const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2692
+ elements.push({ tag: "markdown", content: `<font color='grey'>${footer}</font>` });
2676
2693
  const template = view.state === "failed" ? "orange" : "blue";
2677
2694
  return {
2678
2695
  schema: "2.0",
@@ -2756,8 +2773,8 @@ export class FeishuBot {
2756
2773
  };
2757
2774
  }
2758
2775
  // Running: phase line + ticking elapsed footer.
2759
- const phaseLine = view.phase === "tool-trim"
2760
- ? (en ? "🧹 Native compaction is slow — switching to fast trim…" : "🧹 原生压缩较慢,已切换快速压缩…")
2776
+ const phaseLine = view.phase === "transcript-trim"
2777
+ ? (en ? "🧹 Semantic compaction unavailable — trimming transcript safely…" : "🧹 语义压缩不可用,正在安全裁剪转录…")
2761
2778
  : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
2762
2779
  const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2763
2780
  return {
@@ -2833,9 +2850,21 @@ export class FeishuBot {
2833
2850
  async deleteMessageById(messageId) {
2834
2851
  await this.client.im.v1.message.delete({ path: { message_id: messageId } });
2835
2852
  }
2853
+ async replyFinalMessage(messageId, text, model) {
2854
+ if (!model?.trim())
2855
+ return this.replyMessage(messageId, text);
2856
+ this.replyModelFooters.set(messageId, model.trim());
2857
+ try {
2858
+ return await this.replyMessage(messageId, text);
2859
+ }
2860
+ finally {
2861
+ this.replyModelFooters.delete(messageId);
2862
+ }
2863
+ }
2836
2864
  async replyMessage(messageId, text) {
2837
2865
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
2838
- const card = this.buildMarkdownCard(text);
2866
+ const model = this.replyModelFooters.get(messageId);
2867
+ const card = this.buildMarkdownCard(text, model);
2839
2868
  try {
2840
2869
  const res = await this.client.im.message.reply({
2841
2870
  path: { message_id: messageId },
@@ -2847,11 +2876,12 @@ export class FeishuBot {
2847
2876
  return res?.data?.message_id || res?.message_id;
2848
2877
  }
2849
2878
  catch {
2850
- // Fallback to plain text if card fails
2879
+ // Fallback to plain text if card fails; retain model attribution.
2880
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
2851
2881
  const res = await this.client.im.message.reply({
2852
2882
  path: { message_id: messageId },
2853
2883
  data: {
2854
- content: JSON.stringify({ text }),
2884
+ content: JSON.stringify({ text: fallbackText }),
2855
2885
  msg_type: "text",
2856
2886
  },
2857
2887
  });
@@ -3179,8 +3209,20 @@ export class FeishuBot {
3179
3209
  /**
3180
3210
  * Send a proactive message to a chat (not a reply).
3181
3211
  */
3212
+ async sendFinalMessage(chatId, text, model) {
3213
+ if (!model?.trim())
3214
+ return this.sendMessage(chatId, text);
3215
+ this.sendModelFooters.set(chatId, model.trim());
3216
+ try {
3217
+ return await this.sendMessage(chatId, text);
3218
+ }
3219
+ finally {
3220
+ this.sendModelFooters.delete(chatId);
3221
+ }
3222
+ }
3182
3223
  async sendMessage(chatId, text) {
3183
- const card = this.buildMarkdownCard(text);
3224
+ const model = this.sendModelFooters.get(chatId);
3225
+ const card = this.buildMarkdownCard(text, model);
3184
3226
  try {
3185
3227
  const res = await this.client.im.message.create({
3186
3228
  params: { receive_id_type: "chat_id" },
@@ -3197,13 +3239,14 @@ export class FeishuBot {
3197
3239
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
3198
3240
  if (this.isOutOfChatError(err))
3199
3241
  this.markCurrentBotUnavailable(chatId, err);
3200
- // Fallback to plain text
3242
+ // Fallback to plain text; retain model attribution.
3201
3243
  try {
3244
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
3202
3245
  const res = await this.client.im.message.create({
3203
3246
  params: { receive_id_type: "chat_id" },
3204
3247
  data: {
3205
3248
  receive_id: chatId,
3206
- content: JSON.stringify({ text }),
3249
+ content: JSON.stringify({ text: fallbackText }),
3207
3250
  msg_type: "text",
3208
3251
  },
3209
3252
  });
@@ -3489,17 +3532,16 @@ export class FeishuBot {
3489
3532
  * Handle /compact command: compress session context.
3490
3533
  */
3491
3534
  /**
3492
- * Compact a session with a fallback chain:
3493
- * 1. OpenClaw's native (LLM-summary) compaction — best semantics.
3494
- * 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
3495
- * the transcript file to drop tool calls/results while keeping the
3496
- * conversation. This needs no model, so it works no matter how large the
3497
- * session is (the native path itself fails to fit an oversized session
3498
- * into the model). A backup is always written before any file change.
3499
- * Returns a structured result describing what happened.
3535
+ * Compact a session with a Gateway-owned fallback chain:
3536
+ * 1. Semantic compaction through `sessions.compact`.
3537
+ * 2. If summarization cannot run, ask the same RPC to retain only a bounded
3538
+ * transcript tail via `maxLines`. This works with legacy JSONL and the
3539
+ * SQLite session store introduced in OpenClaw 2026.8.x, and preserves
3540
+ * OpenClaw's write locks, transcript invariants, and migration ownership.
3541
+ *
3542
+ * LMA deliberately never opens or rewrites OpenClaw transcript storage.
3500
3543
  */
3501
3544
  async compactWithFallback(sessionKey, onPhase) {
3502
- // 1. Native compaction first.
3503
3545
  let nativeReason;
3504
3546
  try {
3505
3547
  const res = await this.openclawClient.compactSession(sessionKey);
@@ -3510,48 +3552,41 @@ export class FeishuBot {
3510
3552
  catch (err) {
3511
3553
  nativeReason = err.message;
3512
3554
  }
3513
- // 2. Fall back to tool-trim on the transcript file. Tell the caller so a
3514
- // progress card can flip from "compacting" to "fast-trim" (this is the
3515
- // slow→still-working transition the user most needs to see).
3516
- onPhase?.("tool-trim");
3517
- let sessionId;
3555
+ onPhase?.("transcript-trim");
3556
+ const maxLines = compactFallbackMaxLines();
3518
3557
  try {
3519
- const info = await this.openclawClient.getSessionInfo(sessionKey);
3520
- sessionId = info?.session?.sessionId;
3521
- }
3522
- catch {
3523
- // ignore; handled below
3524
- }
3525
- if (!sessionId) {
3526
- return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
3527
- }
3528
- const filePath = resolveSessionFilePath(sessionKey, sessionId);
3529
- if (!filePath) {
3530
- return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
3531
- }
3532
- const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
3533
- if (!trim.ok) {
3534
- return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
3558
+ const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines });
3559
+ if (trimmed?.compacted === true) {
3560
+ const kept = typeof trimmed?.kept === "number" ? trimmed.kept : maxLines;
3561
+ return {
3562
+ compacted: true,
3563
+ method: "transcript-trim",
3564
+ detail: `kept ${kept} transcript events`,
3565
+ };
3566
+ }
3567
+ const trimReason = typeof trimmed?.reason === "string" ? trimmed.reason : undefined;
3568
+ return {
3569
+ compacted: false,
3570
+ method: "none",
3571
+ reason: trimReason || nativeReason || "transcript trim made no change",
3572
+ };
3535
3573
  }
3536
- const before = trim.bytesBefore || 0;
3537
- const after = trim.bytesAfter || 0;
3538
- if (before > 0 && after >= before) {
3539
- // Nothing meaningful trimmed.
3540
- return { compacted: false, method: "none", reason: trim.reason || "no tool content to trim" };
3574
+ catch (err) {
3575
+ const trimReason = err.message;
3576
+ return {
3577
+ compacted: false,
3578
+ method: "none",
3579
+ reason: nativeReason
3580
+ ? `semantic compaction: ${nativeReason}; transcript trim: ${trimReason}`
3581
+ : trimReason,
3582
+ };
3541
3583
  }
3542
- const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
3543
- const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
3544
- return {
3545
- compacted: true,
3546
- method: "tool-trim",
3547
- detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
3548
- };
3549
3584
  }
3550
3585
  async handleCompactCommand(chatId, messageId) {
3551
3586
  const sessionKey = this.getSessionKey(chatId);
3552
3587
  const en = this.isEn(chatId);
3553
3588
  // Large sessions can take tens of seconds to compact (native compaction may
3554
- // even time out before the tool-trim fallback kicks in). Show a ticking
3589
+ // even time out before the transcript-trim fallback kicks in). Show a ticking
3555
3590
  // "compacting…" card so the user can SEE it is working instead of staring at
3556
3591
  // nothing and assuming nothing is happening. The card is created lazily, so
3557
3592
  // a fast compaction that finishes quickly never flashes a card at all.
@@ -3563,19 +3598,24 @@ export class FeishuBot {
3563
3598
  progress.start();
3564
3599
  try {
3565
3600
  const r = await this.compactWithFallback(sessionKey, (phase) => {
3566
- if (phase === "tool-trim")
3567
- void progress.toToolTrim().catch(() => { });
3601
+ if (phase === "transcript-trim")
3602
+ void progress.toTranscriptTrim().catch(() => { });
3568
3603
  });
3569
3604
  if (r.compacted) {
3570
3605
  // If a card was shown, patch it in place; otherwise (fast finish, no card)
3571
3606
  // fall back to a normal reply so the user still gets confirmation.
3572
- const detail = r.method === "tool-trim" && r.detail
3573
- ? (en ? `tool-trim ${r.detail}` : `删工具调用 ${r.detail}`)
3607
+ const localizedTrimDetail = r.detail
3608
+ ?.replace("kept ", en ? "kept " : "保留最近 ")
3609
+ .replace(" transcript events", en ? " transcript events" : " 条转录事件");
3610
+ const detail = r.method === "transcript-trim" && localizedTrimDetail
3611
+ ? (en ? `transcript trim: ${localizedTrimDetail}` : `转录裁剪:${localizedTrimDetail}`)
3574
3612
  : (en ? "native" : "原生压缩");
3575
3613
  await progress.done(detail);
3576
3614
  if (!progress.id) {
3577
- await this.replyMessage(messageId, r.method === "tool-trim"
3578
- ? (en ? `✅ Session compacted (tool-trim${r.detail ? " " + r.detail : ""})` : `✅ Session 已压缩(删工具调用${r.detail ? " " + r.detail : ""})`)
3615
+ await this.replyMessage(messageId, r.method === "transcript-trim"
3616
+ ? (en
3617
+ ? `✅ Session compacted (transcript trim${localizedTrimDetail ? ": " + localizedTrimDetail : ""})`
3618
+ : `✅ Session 已压缩(转录裁剪${localizedTrimDetail ? ":" + localizedTrimDetail : ""})`)
3579
3619
  : (en ? `✅ Session compacted` : `✅ Session 已压缩`));
3580
3620
  }
3581
3621
  }