openclaw-lark-multi-agent 1.4.4 → 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); }
@@ -101,8 +104,15 @@ export class FeishuBot {
101
104
  lastRealDeliveryAt = new Map();
102
105
  /** Per-chat: whether we've already sent the high-context alert this cycle. */
103
106
  contextAlerted = new Map();
104
- /** 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. */
105
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();
106
116
  adminOpenId;
107
117
  locale;
108
118
  configPath;
@@ -225,7 +235,10 @@ export class FeishuBot {
225
235
  }
226
236
  }
227
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.
228
240
  console.warn(`[${this.config.name}] ensureSession error:`, err.message);
241
+ throw err;
229
242
  }
230
243
  this.initializedSessions.add(sessionKey);
231
244
  // Subscribe to session events (proactive messages + tool calls)
@@ -237,11 +250,17 @@ export class FeishuBot {
237
250
  const parsed = this.extractBridgeAttachments(text);
238
251
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
239
252
  this.cancelDelayedFailure(chatId);
240
- const activeTarget = this.activeDeliveryTargets.get(chatId);
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);
241
258
  try {
242
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);
243
260
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
244
261
  await activeTarget?.liveStatus?.complete().catch(() => { });
262
+ if (meta?.runId && runTarget)
263
+ this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
245
264
  }
246
265
  }
247
266
  catch (err) {
@@ -923,44 +942,81 @@ export class FeishuBot {
923
942
  // Wrap the message so the agent knows it is a NEW user message inserted
924
943
  // mid-task. The live-status line and reactions still key off the original.
925
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);
926
978
  const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
927
979
  if (steer.status === "steered") {
928
- // Accepted into the active run's steer queue, but NOT yet consumed by
929
- // the model. Show "Typing" (awaiting insertion) now; flip to "Get" only
930
- // when the model actually consumes it at a tool-call boundary (a
931
- // matching sessionUser event), which also renders it on the live-status
932
- // card in correct time order.
933
- await this.addReaction(messageId, "Typing").catch(() => { });
934
- pending.push({ messageId, emoji: "Typing", rowId: insertedId });
935
- this.pendingAckMessages.set(chatId, pending);
936
- const norm = cleanText.replace(/\s+/g, " ").trim();
937
- this.openclawClient.onSteerConsumed(sessionKeyForSteer, (consumedText) => {
938
- const cn = (consumedText || "").replace(/\s+/g, " ").trim();
939
- if (cn !== norm && !cn.includes(norm))
940
- return false; // not ours; keep waiting
941
- // Flip Typing -> Get now that the model has actually read it.
942
- void this.removeReaction(messageId, "Typing").catch(() => { });
943
- void this.addReaction(messageId, "Get").catch(() => { });
944
- const acks = this.pendingAckMessages.get(chatId) || [];
945
- for (const a of acks)
946
- if (a.messageId === messageId)
947
- a.emoji = "Get";
948
- this.pendingAckMessages.set(chatId, acks);
949
- return true; // one-shot: matched, remove this callback
950
- });
951
- console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, STEERED (awaiting consumption): "${cleanText.substring(0, 50)}..."`);
952
- // The message is already in DB with a pending trigger. Clear that
953
- // trigger so it is NOT re-run as a separate follow-up batch after the
954
- // current run ends — it has already been injected into this run.
955
- if (insertedId > 0)
956
- 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)}..."`);
957
1000
  return;
958
1001
  }
959
- // Could not steer (no active run raced to idle, rejected, or plugin
960
- // unavailable): fall back to the original behavior — queue and wait.
961
- await this.addReaction(messageId, "Typing").catch(() => { });
962
- pending.push({ messageId, emoji: "Typing", rowId: insertedId });
963
- 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
+ }
964
1020
  console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
965
1021
  return; // Message is in DB, will be picked up when agent finishes
966
1022
  }
@@ -1190,6 +1246,16 @@ export class FeishuBot {
1190
1246
  const runStartStopEpoch = this.stopEpoch.get(chatId) || 0;
1191
1247
  const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
1192
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
+ }
1193
1259
  const droppedTriggerIds = droppedMergedTriggers.map((m) => m.id || 0).filter(Boolean);
1194
1260
  const mergedTriggerIdSet = new Set(mergedTriggerIds);
1195
1261
  const completedTriggerIdSet = new Set([...droppedTriggerIds, ...mergedTriggerIds]);
@@ -1238,7 +1304,28 @@ export class FeishuBot {
1238
1304
  }
1239
1305
  const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
1240
1306
  const queueStartedAt = Date.now();
1241
- 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
+ }
1242
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})`);
1243
1330
  // Update reactions: queued messages → sent (GET/了解)
1244
1331
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
@@ -1304,6 +1391,12 @@ export class FeishuBot {
1304
1391
  },
1305
1392
  onSubmitted: (runId) => {
1306
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
+ }
1307
1400
  // Refine state for logs/idempotent sync rows when OpenClaw returns
1308
1401
  // a runId. markSubmittedBatch is idempotent due to local flags and
1309
1402
  // INSERT OR IGNORE in message_sync.
@@ -2012,18 +2105,48 @@ export class FeishuBot {
2012
2105
  clearTimeout(timer);
2013
2106
  this.delayedFailureTimers.delete(chatId);
2014
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
+ }
2015
2138
  setActiveDeliveryTarget(chatId, triggerId, messageId) {
2016
2139
  const existing = this.activeDeliveryTargets.get(chatId);
2017
2140
  if (existing?.timer)
2018
2141
  clearTimeout(existing.timer);
2019
2142
  const token = Symbol(`${chatId}:${triggerId}`);
2020
2143
  this.activeDeliveryTargets.set(chatId, { triggerId, messageId, token });
2021
- return () => {
2144
+ return (delayMs = 60_000) => {
2022
2145
  const timer = setTimeout(() => {
2023
2146
  const current = this.activeDeliveryTargets.get(chatId);
2024
2147
  if (current?.token === token)
2025
2148
  this.activeDeliveryTargets.delete(chatId);
2026
- }, 60_000);
2149
+ }, delayMs);
2027
2150
  const current = this.activeDeliveryTargets.get(chatId);
2028
2151
  if (current?.token === token)
2029
2152
  current.timer = timer;
@@ -2650,8 +2773,8 @@ export class FeishuBot {
2650
2773
  };
2651
2774
  }
2652
2775
  // Running: phase line + ticking elapsed footer.
2653
- const phaseLine = view.phase === "tool-trim"
2654
- ? (en ? "🧹 Native compaction is slow — switching to fast trim…" : "🧹 原生压缩较慢,已切换快速压缩…")
2776
+ const phaseLine = view.phase === "transcript-trim"
2777
+ ? (en ? "🧹 Semantic compaction unavailable — trimming transcript safely…" : "🧹 语义压缩不可用,正在安全裁剪转录…")
2655
2778
  : (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
2656
2779
  const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2657
2780
  return {
@@ -3409,17 +3532,16 @@ export class FeishuBot {
3409
3532
  * Handle /compact command: compress session context.
3410
3533
  */
3411
3534
  /**
3412
- * Compact a session with a fallback chain:
3413
- * 1. OpenClaw's native (LLM-summary) compaction — best semantics.
3414
- * 2. If that did not compact (or threw), fall back to "tool-trim": rewrite
3415
- * the transcript file to drop tool calls/results while keeping the
3416
- * conversation. This needs no model, so it works no matter how large the
3417
- * session is (the native path itself fails to fit an oversized session
3418
- * into the model). A backup is always written before any file change.
3419
- * 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.
3420
3543
  */
3421
3544
  async compactWithFallback(sessionKey, onPhase) {
3422
- // 1. Native compaction first.
3423
3545
  let nativeReason;
3424
3546
  try {
3425
3547
  const res = await this.openclawClient.compactSession(sessionKey);
@@ -3430,48 +3552,41 @@ export class FeishuBot {
3430
3552
  catch (err) {
3431
3553
  nativeReason = err.message;
3432
3554
  }
3433
- // 2. Fall back to tool-trim on the transcript file. Tell the caller so a
3434
- // progress card can flip from "compacting" to "fast-trim" (this is the
3435
- // slow→still-working transition the user most needs to see).
3436
- onPhase?.("tool-trim");
3437
- let sessionId;
3555
+ onPhase?.("transcript-trim");
3556
+ const maxLines = compactFallbackMaxLines();
3438
3557
  try {
3439
- const info = await this.openclawClient.getSessionInfo(sessionKey);
3440
- sessionId = info?.session?.sessionId;
3441
- }
3442
- catch {
3443
- // ignore; handled below
3444
- }
3445
- if (!sessionId) {
3446
- return { compacted: false, method: "none", reason: nativeReason || "could not resolve session file" };
3447
- }
3448
- const filePath = resolveSessionFilePath(sessionKey, sessionId);
3449
- if (!filePath) {
3450
- return { compacted: false, method: "none", reason: nativeReason || "transcript file not found" };
3451
- }
3452
- const trim = toolTrimCompactFile(filePath, toolTrimKeepRecent());
3453
- if (!trim.ok) {
3454
- 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
+ };
3455
3573
  }
3456
- const before = trim.bytesBefore || 0;
3457
- const after = trim.bytesAfter || 0;
3458
- if (before > 0 && after >= before) {
3459
- // Nothing meaningful trimmed.
3460
- 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
+ };
3461
3583
  }
3462
- const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
3463
- const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
3464
- return {
3465
- compacted: true,
3466
- method: "tool-trim",
3467
- detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
3468
- };
3469
3584
  }
3470
3585
  async handleCompactCommand(chatId, messageId) {
3471
3586
  const sessionKey = this.getSessionKey(chatId);
3472
3587
  const en = this.isEn(chatId);
3473
3588
  // Large sessions can take tens of seconds to compact (native compaction may
3474
- // 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
3475
3590
  // "compacting…" card so the user can SEE it is working instead of staring at
3476
3591
  // nothing and assuming nothing is happening. The card is created lazily, so
3477
3592
  // a fast compaction that finishes quickly never flashes a card at all.
@@ -3483,19 +3598,24 @@ export class FeishuBot {
3483
3598
  progress.start();
3484
3599
  try {
3485
3600
  const r = await this.compactWithFallback(sessionKey, (phase) => {
3486
- if (phase === "tool-trim")
3487
- void progress.toToolTrim().catch(() => { });
3601
+ if (phase === "transcript-trim")
3602
+ void progress.toTranscriptTrim().catch(() => { });
3488
3603
  });
3489
3604
  if (r.compacted) {
3490
3605
  // If a card was shown, patch it in place; otherwise (fast finish, no card)
3491
3606
  // fall back to a normal reply so the user still gets confirmation.
3492
- const detail = r.method === "tool-trim" && r.detail
3493
- ? (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}`)
3494
3612
  : (en ? "native" : "原生压缩");
3495
3613
  await progress.done(detail);
3496
3614
  if (!progress.id) {
3497
- await this.replyMessage(messageId, r.method === "tool-trim"
3498
- ? (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 : ""})`)
3499
3619
  : (en ? `✅ Session compacted` : `✅ Session 已压缩`));
3500
3620
  }
3501
3621
  }
@@ -21,7 +21,7 @@ export type ChatAttachment = {
21
21
  fileName?: string;
22
22
  content: string;
23
23
  };
24
- export declare const GATEWAY_PROTOCOL_MIN = 3;
24
+ export declare const GATEWAY_PROTOCOL_MIN = 4;
25
25
  export declare const GATEWAY_PROTOCOL_MAX = 4;
26
26
  /**
27
27
  * OpenClaw Gateway WebSocket client.
@@ -40,11 +40,14 @@ export declare class OpenClawClient {
40
40
  /** Callbacks for tool events (verbose mode) */
41
41
  private toolEventCallbacks;
42
42
  private lastToolNamesByItemId;
43
+ /** Deduplicates 2026.8 canonical `agent.stream=tool` events against legacy
44
+ * `stream=item` mirrors when a runtime emits both representations. */
45
+ private recentToolEventKeys;
43
46
  private sessionMessageCallbacks;
44
47
  private progressCallbacks;
45
- /** Per-session steered messages awaiting consumption confirmation. Maps the
46
- * normalized text actually sent (possibly wrapped with an insertion prefix)
47
- * to the ORIGINAL display text shown on the live-status card. */
48
+ /** Per-session steered messages awaiting consumption confirmation. Each
49
+ * accepted message is retained independently so identical/overlapping text
50
+ * cannot overwrite another correlation. */
48
51
  private pendingSteerTexts;
49
52
  /** Sessions where verbose mode should deliver visible transcript text even when mixed with tool blocks. */
50
53
  private verboseTranscriptSessions;
@@ -57,9 +60,11 @@ export declare class OpenClawClient {
57
60
  /** Session keys whose transcript/session.message updates are currently suppressed. */
58
61
  private suppressedSessions;
59
62
  private suppressedSessionTimers;
60
- /** Session keys whose delivery is owned by this bridge's chatSend final path. */
61
- private ownedDeliverySessions;
62
- private ownedDeliverySessionTimers;
63
+ /** Run ids whose final delivery is owned by chatSend/collectReply. Delivery
64
+ * ownership must be run-scoped: a concurrent queueMode=steer admission may
65
+ * fall through to a separate ordinary run in the same session. */
66
+ private ownedDeliveryRuns;
67
+ private ownedDeliveryRunTimers;
63
68
  /** Session keys whose proactive messages must be dropped by the bridge (e.g. discussion scheduler owns delivery). */
64
69
  private mutedProactiveSessions;
65
70
  private mutedProactiveSessionCounts;
@@ -83,6 +88,7 @@ export declare class OpenClawClient {
83
88
  private extractVisibleAssistantText;
84
89
  private isVerboseTranscriptEnabled;
85
90
  private clearVerboseAssistantState;
91
+ private claimToolEvent;
86
92
  private pruneVerboseCaches;
87
93
  private flushVerboseAssistantState;
88
94
  private handleVerboseAssistantStream;
@@ -128,21 +134,19 @@ export declare class OpenClawClient {
128
134
  label?: string;
129
135
  }): Promise<any>;
130
136
  /**
131
- * Steer a message into the ACTIVE run for a session via the lma-steer plugin's
132
- * `lma.steer` gateway method. Unlike a plain new message, this injects the text
133
- * at the next tool-call boundary of the run that is currently executing, so the
134
- * user can nudge/correct a long run instead of waiting for it to finish.
135
- *
136
- * Returns a structured status so the bridge can render the right reaction:
137
- * - "steered" -> queued into the active run (will be seen next boundary)
138
- * - "no_active_run" -> no active run; caller should fall back to normal queueing
139
- * - "rejected" -> active run refused the injection (compacting/not streaming)
140
- * - "unavailable" -> the lma-steer plugin/method is not available (e.g. not
141
- * installed or gateway not restarted); caller falls back.
137
+ * Submit a mid-run message through OpenClaw's public atomic steer path.
138
+ * OpenClaw 2026.8 deprecated the synchronous plugin primitive previously used
139
+ * by `lma-steer`: its boolean only represented immediate eligibility and could
140
+ * still be rejected asynchronously. `chat.send queueMode=steer` owns admission
141
+ * and transcript persistence. LMA still treats the RPC result as provisional;
142
+ * the Feishu reaction flips to Get only after a matching `session.message`
143
+ * confirms that the input was committed/consumed.
142
144
  */
143
145
  steer(sessionKey: string, text: string, displayText?: string): Promise<{
144
- status: "steered" | "no_active_run" | "rejected" | "unavailable";
145
- sessionId?: string;
146
+ status: "steered" | "unavailable";
147
+ runId?: string;
148
+ /** Cancel provisional transcript correlation when LMA falls back to a normal run. */
149
+ cancelPending?: () => void;
146
150
  }>;
147
151
  /** Callbacks fired when a steered message is confirmed consumed by the model
148
152
  * (matching sessionUser event). Keyed by bare/short session key. Multiple may
@@ -154,8 +158,9 @@ export declare class OpenClawClient {
154
158
  * is actually consumed by the active run. The bridge uses this to flip the
155
159
  * Feishu reaction from "Typing" (awaiting insertion) to "Get" (inserted).
156
160
  * Return true from the callback to consume/remove it (one-shot). */
157
- onSteerConsumed(sessionKey: string, cb: (text: string) => boolean | void): void;
161
+ onSteerConsumed(sessionKey: string, matchText: string, cb: (text: string) => boolean | void): () => void;
158
162
  private shortKey;
163
+ private canonicalSessionKey;
159
164
  private registerPendingSteer;
160
165
  /**
161
166
  * Called when a sessionUser event is seen for a session. If its text matches a
@@ -175,7 +180,9 @@ export declare class OpenClawClient {
175
180
  ensureModel(sessionKey: string, expectedModel: string): Promise<boolean>;
176
181
  deleteSession(key: string, deleteTranscript?: boolean): Promise<any>;
177
182
  resetSession(key: string): Promise<any>;
178
- compactSession(key: string): Promise<any>;
183
+ compactSession(key: string, options?: {
184
+ maxLines?: number;
185
+ }): Promise<any>;
179
186
  /**
180
187
  * Send a message to a session and get the agent reply.
181
188
  * deliver=false prevents OpenClaw from auto-posting to channels.
@@ -187,9 +194,8 @@ export declare class OpenClawClient {
187
194
  private emitProactiveForSession;
188
195
  private suppressSessionKeys;
189
196
  private releaseSuppressedSessionKeysAfter;
190
- private ownDeliverySessionKeys;
191
- private releaseOwnedDeliverySessionKeysAfter;
192
- private isOwnedDeliverySession;
197
+ private ownDeliveryRun;
198
+ private releaseOwnedDeliveryRunAfter;
193
199
  private addMutedProactiveKey;
194
200
  private releaseMutedProactiveKey;
195
201
  muteProactiveDelivery(sessionKey: string): (delayMs?: number) => void;
@@ -253,6 +259,7 @@ export declare class OpenClawClient {
253
259
  */
254
260
  subscribeSession(sessionKey: string, onMessage: (text: string, meta?: {
255
261
  sourceType?: string;
262
+ runId?: string;
256
263
  }) => void): Promise<void>;
257
264
  setVerboseTranscriptDelivery(sessionKey: string, enabled: boolean): void;
258
265
  unsubscribeSession(sessionKey: string): Promise<void>;