openclaw-lark-multi-agent 1.0.6 → 1.0.8

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.
@@ -98,6 +98,7 @@ export declare class FeishuBot {
98
98
  private runDiscussionTurn;
99
99
  private replyMessage;
100
100
  private extractBridgeAttachments;
101
+ private pushBridgeAttachment;
101
102
  private validateBridgeAttachmentPath;
102
103
  private inferFeishuFileType;
103
104
  private isImagePath;
@@ -2,14 +2,13 @@ import * as lark from "@larksuiteoapi/node-sdk";
2
2
  import { createRequire } from "module";
3
3
  import { existsSync, readFileSync, statSync } from "fs";
4
4
  import { basename, extname, resolve } from "path";
5
- import { getBridgeAttachmentAllowedRoots, getBridgeAttachmentsDir } from "./paths.js";
5
+ import { getBridgeAttachmentsDir } from "./paths.js";
6
6
  import { buildFeishuCardElements } from "./markdown.js";
7
7
  import { discussionManager } from "./discussion-manager.js";
8
8
  const require = createRequire(import.meta.url);
9
9
  const LMA_VERSION = require("../package.json").version;
10
10
  const MAX_BOT_STREAK = 10;
11
11
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
12
- const BRIDGE_ATTACHMENT_ALLOWED_ROOTS = getBridgeAttachmentAllowedRoots();
13
12
  /**
14
13
  * Manages a single Feishu bot instance.
15
14
  *
@@ -637,25 +636,27 @@ export class FeishuBot {
637
636
  }
638
637
  async processQueueInner(chatId) {
639
638
  while (true) {
640
- const unsyncedMessages = this.store.getUnsyncedMessages(this.config.name, chatId);
641
639
  const pendingMessages = this.store.getPendingTriggerMessages(this.config.name, chatId);
642
- const messageById = new Map();
643
- for (const msg of [...unsyncedMessages, ...pendingMessages]) {
644
- if (msg.id)
645
- messageById.set(msg.id, msg);
646
- }
647
- const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
648
640
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
649
641
  // Only proceed if there are pending human messages that should actively trigger this bot.
650
- // Pending triggers are included even if a later bridge command has advanced sync_state.
651
- const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
652
- if (humanUnsynced.length === 0) {
642
+ const pendingHumanTriggers = pendingMessages
643
+ .filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id))
644
+ .sort((a, b) => a.timestamp - b.timestamp);
645
+ if (pendingHumanTriggers.length === 0) {
653
646
  break;
654
647
  }
655
648
  this.busyChats.set(chatId, Date.now());
656
- // The last trigger message is the "current" one, everything else is context
657
- const lastHuman = humanUnsynced[humanUnsynced.length - 1];
649
+ // The last trigger message is the "current" one, everything else is context.
650
+ const lastHuman = pendingHumanTriggers[pendingHumanTriggers.length - 1];
658
651
  const triggerId = lastHuman.id || 0;
652
+ const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
653
+ const messageById = new Map();
654
+ for (const msg of [...catchupMessages, ...pendingMessages]) {
655
+ if (msg.id)
656
+ messageById.set(msg.id, msg);
657
+ }
658
+ const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
659
+ const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
659
660
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
660
661
  console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
661
662
  this.store.clearPendingTriggers(this.config.name, chatId, triggerId);
@@ -716,6 +717,8 @@ export class FeishuBot {
716
717
  // must remain pending for the next loop.
717
718
  const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
718
719
  const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
720
+ const syncBatchId = `${this.config.name}:${chatId}:${triggerId}:${Date.now()}`;
721
+ this.store.markMessagesSynced(this.config.name, chatId, allUnsynced.map((m) => m.id || 0), syncBatchId);
719
722
  this.store.markSynced(this.config.name, chatId, maxId);
720
723
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
721
724
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
@@ -739,8 +742,10 @@ export class FeishuBot {
739
742
  // Do not advance sync past a human message that arrived while this
740
743
  // run was busy. Otherwise the pending trigger remains in the table
741
744
  // but getUnsyncedMessages() can no longer see it.
742
- if (!hasEarlierPending)
745
+ if (!hasEarlierPending) {
746
+ this.store.markMessagesSynced(this.config.name, chatId, [replyId], `${this.config.name}:${chatId}:reply:${replyId}`);
743
747
  this.store.markSynced(this.config.name, chatId, replyId);
748
+ }
744
749
  }
745
750
  }
746
751
  // Wait for all pending tool event messages to be delivered first
@@ -756,9 +761,17 @@ export class FeishuBot {
756
761
  console.warn(`[${this.config.name}] Reply already delivered, skip duplicate for ${chatId.slice(-8)} msgId=${triggerId}`);
757
762
  }
758
763
  else {
759
- await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
760
- if (triggerId)
761
- this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
764
+ try {
765
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
766
+ if (triggerId)
767
+ this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
768
+ }
769
+ catch (err) {
770
+ // enqueueAndDispatchDelivery already sent a user-visible delivery
771
+ // failure. Do not fall through to the generic provider-error path;
772
+ // that creates a second misleading "bot did not complete" message.
773
+ console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
774
+ }
762
775
  }
763
776
  }
764
777
  if (isRuntimeFailure && lastHuman.messageId) {
@@ -1172,29 +1185,39 @@ export class FeishuBot {
1172
1185
  try {
1173
1186
  const parsed = JSON.parse(String(jsonText).trim());
1174
1187
  const rawAttachments = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.attachments) ? parsed.attachments : [parsed];
1175
- for (const item of rawAttachments) {
1176
- if (!item || typeof item.path !== "string")
1177
- continue;
1178
- attachments.push({
1179
- type: item.type === "image" || item.type === "document" || item.type === "file" ? item.type : undefined,
1180
- path: item.path,
1181
- caption: typeof item.caption === "string" ? item.caption : undefined,
1182
- });
1183
- }
1188
+ for (const item of rawAttachments)
1189
+ this.pushBridgeAttachment(attachments, item);
1184
1190
  }
1185
1191
  catch (err) {
1186
1192
  console.warn(`[${this.config.name}] Failed to parse bridge attachment marker:`, err.message);
1187
1193
  }
1188
1194
  return "";
1195
+ });
1196
+ // Compatibility fallback for agents that use OpenClaw's channel directive
1197
+ // syntax instead of the LMA marker. In Feishu/LMA, leaving MEDIA:<path> in
1198
+ // plain text only shows the path, so parse it into bridge attachments.
1199
+ const mediaDirectivePattern = /^\s*MEDIA:(.+)$/gm;
1200
+ text = text.replace(mediaDirectivePattern, (_match, rawPath) => {
1201
+ const mediaPath = String(rawPath).trim();
1202
+ if (!mediaPath)
1203
+ return "";
1204
+ const cleanPath = mediaPath.replace(/^['\"]|['\"]$/g, "");
1205
+ attachments.push({ type: this.isImagePath(cleanPath) ? "image" : "file", path: cleanPath });
1206
+ return "";
1189
1207
  }).trim();
1190
1208
  return { text, attachments };
1191
1209
  }
1210
+ pushBridgeAttachment(attachments, item) {
1211
+ if (!item || typeof item.path !== "string")
1212
+ return;
1213
+ attachments.push({
1214
+ type: item.type === "image" || item.type === "document" || item.type === "file" ? item.type : undefined,
1215
+ path: item.path,
1216
+ caption: typeof item.caption === "string" ? item.caption : undefined,
1217
+ });
1218
+ }
1192
1219
  validateBridgeAttachmentPath(filePath) {
1193
1220
  const resolvedPath = resolve(filePath);
1194
- const isAllowed = BRIDGE_ATTACHMENT_ALLOWED_ROOTS.some((root) => resolvedPath === root || resolvedPath.startsWith(root + "/"));
1195
- if (!isAllowed) {
1196
- throw new Error(`Attachment path outside allowed directories (${BRIDGE_ATTACHMENT_ALLOWED_ROOTS.join(", ")}): ${resolvedPath}`);
1197
- }
1198
1221
  if (!existsSync(resolvedPath))
1199
1222
  throw new Error(`Attachment file not found: ${resolvedPath}`);
1200
1223
  const stats = statSync(resolvedPath);
@@ -1628,7 +1651,8 @@ export class FeishuBot {
1628
1651
  async downloadResource(messageId, fileKey, type) {
1629
1652
  const { mkdirSync, writeFileSync } = await import("fs");
1630
1653
  const { resolve } = await import("path");
1631
- const dir = resolve(process.cwd(), "data", "media");
1654
+ const { getStateDir } = await import("./paths.js");
1655
+ const dir = resolve(getStateDir(), "data", "media");
1632
1656
  mkdirSync(dir, { recursive: true });
1633
1657
  const resp = await this.client.im.messageResource.get({
1634
1658
  path: { message_id: messageId, file_key: fileKey },
@@ -74,6 +74,12 @@ export declare class MessageStore {
74
74
  * Returns messages ordered by timestamp ascending.
75
75
  */
76
76
  getUnsyncedMessages(botName: string, chatId: string, maxCount?: number): ChatMessage[];
77
+ /**
78
+ * Get all messages at or before triggerRowId that have not been synced to a
79
+ * bot's session yet. Ordered by timestamp ascending.
80
+ */
81
+ getUnsyncedMessagesForBot(botName: string, chatId: string, triggerRowId: number): ChatMessage[];
82
+ markMessagesSynced(botName: string, chatId: string, messageRowIds: number[], syncBatchId?: string): void;
77
83
  /**
78
84
  * Mark all messages up to (and including) the given id as synced for a bot.
79
85
  */
@@ -23,6 +23,7 @@ export class MessageStore {
23
23
  timestamp INTEGER NOT NULL
24
24
  );
25
25
  CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_id, timestamp);
26
+ CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id);
26
27
 
27
28
  CREATE TABLE IF NOT EXISTS chat_info (
28
29
  chat_id TEXT PRIMARY KEY,
@@ -36,7 +37,8 @@ export class MessageStore {
36
37
  updated_at INTEGER NOT NULL DEFAULT 0
37
38
  );
38
39
 
39
- -- Tracks which messages have been synced to each bot's OpenClaw session.
40
+ -- Legacy high-water mark for which messages have been synced to each
41
+ -- bot's OpenClaw session. Kept for compatibility and quick inspection.
40
42
  CREATE TABLE IF NOT EXISTS sync_state (
41
43
  bot_name TEXT NOT NULL,
42
44
  chat_id TEXT NOT NULL,
@@ -44,6 +46,18 @@ export class MessageStore {
44
46
  PRIMARY KEY (bot_name, chat_id)
45
47
  );
46
48
 
49
+ -- Per-message sync ledger. This lets a bot catch up on all missed group
50
+ -- messages without relying only on a coarse high-water mark.
51
+ CREATE TABLE IF NOT EXISTS message_sync (
52
+ bot_name TEXT NOT NULL,
53
+ chat_id TEXT NOT NULL,
54
+ message_row_id INTEGER NOT NULL,
55
+ synced_at INTEGER NOT NULL,
56
+ sync_batch_id TEXT NOT NULL DEFAULT '',
57
+ PRIMARY KEY (bot_name, chat_id, message_row_id)
58
+ );
59
+ CREATE INDEX IF NOT EXISTS idx_message_sync_bot_chat ON message_sync(bot_name, chat_id, message_row_id);
60
+
47
61
  -- Tracks which messages have been processed by each bot (multi-bot dedup).
48
62
  CREATE TABLE IF NOT EXISTS processed_events (
49
63
  bot_name TEXT NOT NULL,
@@ -428,6 +442,48 @@ export class MessageStore {
428
442
  timestamp: r.timestamp,
429
443
  }));
430
444
  }
445
+ /**
446
+ * Get all messages at or before triggerRowId that have not been synced to a
447
+ * bot's session yet. Ordered by timestamp ascending.
448
+ */
449
+ getUnsyncedMessagesForBot(botName, chatId, triggerRowId) {
450
+ const rows = this.db.prepare(`
451
+ SELECT messages.* FROM messages
452
+ LEFT JOIN recalled_messages ON recalled_messages.message_id = messages.message_id
453
+ LEFT JOIN message_sync ON message_sync.bot_name = ?
454
+ AND message_sync.chat_id = messages.chat_id
455
+ AND message_sync.message_row_id = messages.id
456
+ WHERE messages.chat_id = ?
457
+ AND messages.id <= ?
458
+ AND recalled_messages.message_id IS NULL
459
+ AND message_sync.message_row_id IS NULL
460
+ ORDER BY messages.timestamp ASC
461
+ `).all(botName, chatId, triggerRowId);
462
+ return rows.map((r) => ({
463
+ id: r.id,
464
+ chatId: r.chat_id,
465
+ messageId: r.message_id,
466
+ senderType: r.sender_type,
467
+ senderName: r.sender_name,
468
+ content: r.content,
469
+ timestamp: r.timestamp,
470
+ }));
471
+ }
472
+ markMessagesSynced(botName, chatId, messageRowIds, syncBatchId = '') {
473
+ const ids = Array.from(new Set(messageRowIds.filter((id) => Number.isFinite(id) && id > 0)));
474
+ if (ids.length === 0)
475
+ return;
476
+ const now = Date.now();
477
+ const stmt = this.db.prepare(`
478
+ INSERT OR IGNORE INTO message_sync (bot_name, chat_id, message_row_id, synced_at, sync_batch_id)
479
+ VALUES (?, ?, ?, ?, ?)
480
+ `);
481
+ const tx = this.db.transaction((rows) => {
482
+ for (const id of rows)
483
+ stmt.run(botName, chatId, id, now, syncBatchId);
484
+ });
485
+ tx(ids);
486
+ }
431
487
  /**
432
488
  * Mark all messages up to (and including) the given id as synced for a bot.
433
489
  */
@@ -39,6 +39,8 @@ export declare class OpenClawClient {
39
39
  constructor(config: OpenClawConfig);
40
40
  connect(): Promise<void>;
41
41
  private _doConnect;
42
+ private extractVisibleUserText;
43
+ private extractVisibleAssistantText;
42
44
  private handleProactiveSessionMessage;
43
45
  private scheduleReconnect;
44
46
  private rpc;
@@ -122,6 +124,8 @@ export declare class OpenClawClient {
122
124
  timeoutMs?: number;
123
125
  emptyFinalAsNoReply?: boolean;
124
126
  }): Promise<string>;
127
+ private formatContextLines;
128
+ private writeContextSyncFile;
125
129
  private extractImageAttachments;
126
130
  disconnect(): Promise<void>;
127
131
  /**
@@ -1,11 +1,14 @@
1
1
  import WebSocket from "ws";
2
2
  import { randomUUID } from "crypto";
3
- import { readFileSync } from "fs";
4
- import { basename, extname } from "path";
5
- import { getBridgeAttachmentsDir } from "./paths.js";
3
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
4
+ import { basename, extname, join } from "path";
5
+ import { getBridgeAttachmentsDir, getStateDir } from "./paths.js";
6
6
  export const GATEWAY_PROTOCOL_MIN = 3;
7
7
  export const GATEWAY_PROTOCOL_MAX = 4;
8
8
  const BRIDGE_ATTACHMENTS_DIR = getBridgeAttachmentsDir();
9
+ const CONTEXT_SYNC_DIR = join(getStateDir(), "context-sync");
10
+ const MAX_INLINE_CONTEXT_MESSAGES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_MESSAGES || 1000);
11
+ const MAX_INLINE_CONTEXT_BYTES = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_MAX_INLINE_CONTEXT_BYTES || 8 * 1024 * 1024);
9
12
  /**
10
13
  * OpenClaw Gateway WebSocket client.
11
14
  * Full agent pipeline — tools, memory, skills, context management by OpenClaw.
@@ -164,6 +167,28 @@ export class OpenClawClient {
164
167
  // Try both raw key and without agent:main: prefix
165
168
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
166
169
  const msg = frame.payload.message || frame.payload;
170
+ const visibleUserText = this.extractVisibleUserText(msg);
171
+ if (visibleUserText) {
172
+ if (!this.agentEvents.has(rawKey))
173
+ this.agentEvents.set(rawKey, []);
174
+ this.agentEvents.get(rawKey).push({
175
+ runId: frame.payload.runId,
176
+ sessionKey: rawKey,
177
+ stream: "sessionUser",
178
+ data: { text: visibleUserText },
179
+ });
180
+ }
181
+ const visibleAssistantText = this.extractVisibleAssistantText(msg);
182
+ if (visibleAssistantText) {
183
+ if (!this.agentEvents.has(rawKey))
184
+ this.agentEvents.set(rawKey, []);
185
+ this.agentEvents.get(rawKey).push({
186
+ runId: frame.payload.runId,
187
+ sessionKey: rawKey,
188
+ stream: "assistant",
189
+ data: { deltaText: visibleAssistantText, delta: visibleAssistantText, replace: true },
190
+ });
191
+ }
167
192
  this.handleProactiveSessionMessage(rawKey, msg);
168
193
  // Tool calls in assistant messages — skip, using agent item events instead
169
194
  // (session.message toolCall events are batched, not real-time)
@@ -196,39 +221,50 @@ export class OpenClawClient {
196
221
  });
197
222
  });
198
223
  }
224
+ extractVisibleUserText(msg) {
225
+ if (msg?.role !== "user")
226
+ return "";
227
+ const content = msg.content;
228
+ if (typeof content === "string")
229
+ return content.trim();
230
+ if (!Array.isArray(content))
231
+ return "";
232
+ return content
233
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
234
+ .map((part) => part.text)
235
+ .join("\n")
236
+ .trim();
237
+ }
238
+ extractVisibleAssistantText(msg) {
239
+ if (msg?.role !== "assistant")
240
+ return "";
241
+ const content = msg.content;
242
+ // Extract only visible text parts and ignore thinking/tool blocks.
243
+ if (typeof content === "string")
244
+ return content.trim();
245
+ if (!Array.isArray(content))
246
+ return "";
247
+ const hasToolBlock = content.some((part) => {
248
+ const type = String(part?.type || "").toLowerCase();
249
+ return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
250
+ });
251
+ // Do not deliver mixed text+toolCall assistant messages through the
252
+ // final-text path; those are usually intermediate tool-loop status.
253
+ if (hasToolBlock)
254
+ return "";
255
+ return content
256
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
257
+ .map((part) => part.text)
258
+ .join("\n")
259
+ .trim();
260
+ }
199
261
  handleProactiveSessionMessage(rawKey, msg) {
200
262
  const shortKey = rawKey.replace(/^agent:[^:]+:/, "");
201
- const role = msg.role;
202
- const content = msg.content;
203
263
  // Proactive assistant text messages. Cron/session-targeted runs often emit
204
264
  // structured content arrays rather than a plain string. Extract only visible
205
265
  // text parts and ignore thinking/tool blocks so the bridge can deliver final
206
266
  // cron results via the bot.
207
- let proactiveText = "";
208
- if (role === "assistant") {
209
- if (typeof content === "string") {
210
- proactiveText = content;
211
- }
212
- else if (Array.isArray(content)) {
213
- const hasToolBlock = content.some((part) => {
214
- const type = String(part?.type || "").toLowerCase();
215
- return type === "toolcall" || type === "tool_call" || type === "tooluse" || type === "tool_use" || type === "toolresult" || type === "tool_result";
216
- });
217
- // Do not deliver mixed text+toolCall assistant messages through
218
- // the proactive final-text path; those are usually intermediate
219
- // reasoning/status during a tool loop. Tool calls are still
220
- // delivered via the verbose channel from agent item events when
221
- // /verbose is enabled. Cron final messages arrive as text-only
222
- // (optionally with thinking).
223
- if (!hasToolBlock) {
224
- proactiveText = content
225
- .filter((part) => part?.type === "text" && typeof part.text === "string")
226
- .map((part) => part.text)
227
- .join("\n")
228
- .trim();
229
- }
230
- }
231
- }
267
+ const proactiveText = this.extractVisibleAssistantText(msg);
232
268
  if (!proactiveText)
233
269
  return false;
234
270
  if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey)) {
@@ -293,12 +329,28 @@ export class OpenClawClient {
293
329
  let text = "";
294
330
  let chatDeltaText = "";
295
331
  let chatFinalText = "";
296
- let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey}` : "";
332
+ let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
333
+ let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
297
334
  let chatFinalTimer = null;
298
335
  let lifecycleEndTimer = null;
299
336
  let replayInvalidTimer = null;
337
+ let pendingRuntimeFailureText = "";
338
+ let lastActivitySummary = "";
339
+ let lastActivityAt = 0;
300
340
  const collectStartedAt = Date.now();
301
341
  let lifecycleStartedLogged = false;
342
+ const expectedUserText = options?.expectedUserText?.trim() || "";
343
+ let anchorSeen = !expectedUserText;
344
+ const activeRunIds = new Set([runId]);
345
+ let preAnchorEvents = [];
346
+ const normalizeText = (value) => value.replace(/\s+/g, " ").trim();
347
+ const isExpectedUserText = (value) => {
348
+ const actual = normalizeText(value);
349
+ const expected = normalizeText(expectedUserText);
350
+ if (!actual || !expected)
351
+ return false;
352
+ return actual === expected || actual.includes(expected) || expected.includes(actual);
353
+ };
302
354
  let idleTimer;
303
355
  const resetIdleTimer = () => {
304
356
  if (idleTimer)
@@ -315,9 +367,69 @@ export class OpenClawClient {
315
367
  this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
316
368
  console.warn(`[OpenClaw] abort after collectReply idle timeout failed:`, err.message);
317
369
  });
318
- resolve(text || chatDeltaText || chatFinalText || "(timeout: no reply received)");
370
+ const visibleText = text || chatDeltaText || chatFinalText;
371
+ if (visibleText) {
372
+ resolve(visibleText);
373
+ }
374
+ else if (pendingRuntimeFailureText) {
375
+ resolve(`${pendingRuntimeFailureText}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
376
+ }
377
+ else if (lastActivitySummary) {
378
+ resolve(`⚠️ Agent 长时间没有产生最终回复\n最后活动: ${lastActivitySummary}\n时间: ${new Date(lastActivityAt).toLocaleString()}\n\nLMA 已连续 ${Math.round(timeoutMs / 60000)} 分钟没有收到新的工具输出或最终回复,已停止等待。`);
379
+ }
380
+ else {
381
+ resolve("(timeout: no reply received)");
382
+ }
319
383
  }, timeoutMs);
320
384
  };
385
+ const summarizeActivity = (ev) => {
386
+ if (ev.stream === "item") {
387
+ const data = ev.data || {};
388
+ if (data.kind === "tool") {
389
+ const phase = data.phase ? ` ${data.phase}` : "";
390
+ const meta = typeof data.meta === "string" && data.meta.trim() ? `: ${data.meta.trim().slice(0, 300)}` : "";
391
+ return `工具${phase}${data.name ? ` ${data.name}` : ""}${meta}`;
392
+ }
393
+ return `运行事件 item${data.kind ? `/${data.kind}` : ""}`;
394
+ }
395
+ if (ev.stream === "assistant" || ev.stream === "chatDelta") {
396
+ const chunk = String(ev.data?.deltaText || ev.data?.delta || "").trim();
397
+ return chunk ? `模型输出片段: ${chunk.slice(0, 300)}` : "模型输出片段";
398
+ }
399
+ if (ev.stream === "chatFinal")
400
+ return "收到 chat final 事件";
401
+ if (ev.stream === "lifecycle") {
402
+ const state = ev.data?.livenessState || ev.data?.status || ev.data?.phase || "unknown";
403
+ const reason = ev.data?.stopReason ? `, reason=${ev.data.stopReason}` : "";
404
+ const replay = ev.data?.replayInvalid ? ", replayInvalid" : "";
405
+ return `运行状态: ${state}${replay}${reason}`;
406
+ }
407
+ return `事件: ${ev.stream || "unknown"}`;
408
+ };
409
+ const rememberActivity = (ev) => {
410
+ // A replay-invalid lifecycle end is the terminal symptom, not the useful
411
+ // last activity. Keep the previous tool/model activity so user-visible
412
+ // timeout messages explain what the agent was actually doing.
413
+ if (ev.stream === "lifecycle" && ev.data?.phase === "end" && ev.data?.replayInvalid && lastActivitySummary)
414
+ return;
415
+ const summary = summarizeActivity(ev);
416
+ if (summary) {
417
+ lastActivitySummary = summary;
418
+ lastActivityAt = Date.now();
419
+ }
420
+ };
421
+ const buildFailureText = (ev) => {
422
+ const state = ev.data?.livenessState || ev.data?.status || "unknown";
423
+ const reason = ev.data?.stopReason || "";
424
+ const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
425
+ const lines = [`⚠️ Agent 未正常完成`, `状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}`];
426
+ if (lastActivitySummary) {
427
+ lines.push(`最后活动: ${lastActivitySummary}`);
428
+ lines.push(`最后活动时间: ${new Date(lastActivityAt).toLocaleString()}`);
429
+ }
430
+ lines.push("请重试,或用 /reset 重置会话");
431
+ return lines.join("\n");
432
+ };
321
433
  resetIdleTimer();
322
434
  const finish = (finalText) => {
323
435
  clearTimeout(idleTimer);
@@ -332,7 +444,7 @@ export class OpenClawClient {
332
444
  };
333
445
  const poller = setInterval(() => {
334
446
  const bucketsToScan = sessionKey
335
- ? [sessionKey]
447
+ ? Array.from(new Set([sessionKey, shortSessionKey].filter(Boolean)))
336
448
  : Array.from(this.agentEvents.keys());
337
449
  for (const bucketKey of bucketsToScan) {
338
450
  const bucket = this.agentEvents.get(bucketKey);
@@ -342,18 +454,74 @@ export class OpenClawClient {
342
454
  while (i < bucket.length) {
343
455
  const ev = bucket[i];
344
456
  const evRunId = typeof ev.runId === "string" ? ev.runId : "";
345
- const matchesRun = evRunId ? evRunId === runId : false;
346
- // OpenClaw chat.send may emit the user-facing chat runId while the actual
347
- // agent lifecycle uses an internal runId. We clear this session's event buffer
348
- // immediately before chat.send, so matching by sessionKey here is safe and
349
- // necessary to collect the real agent output.
350
- const matchesSession = sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey);
351
- if (matchesRun || matchesSession) {
352
- if (!sessionKey && ev.sessionKey)
353
- sessionKey = ev.sessionKey;
457
+ const eventSessionMatches = Boolean(sessionKey && (ev.sessionKey === sessionKey || ev.sessionKey === targetSessionKey || ev.sessionKey === shortSessionKey));
458
+ const matchesRun = evRunId ? activeRunIds.has(evRunId) : false;
459
+ if (!sessionKey && ev.sessionKey) {
460
+ shortSessionKey = String(ev.sessionKey).replace(/^agent:[^:]+:/, "");
461
+ sessionKey = `agent:main:${shortSessionKey}`;
462
+ }
463
+ if (eventSessionMatches && ev.stream === "sessionUser") {
464
+ bucket.splice(i, 1);
465
+ if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
466
+ anchorSeen = true;
467
+ resetIdleTimer();
468
+ for (const pendingEv of preAnchorEvents) {
469
+ const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
470
+ if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
471
+ activeRunIds.add(pendingRunId);
472
+ }
473
+ }
474
+ const replay = preAnchorEvents.filter((pendingEv) => {
475
+ const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
476
+ return !pendingRunId || activeRunIds.has(pendingRunId);
477
+ });
478
+ preAnchorEvents = [];
479
+ if (replay.length)
480
+ bucket.splice(i, 0, ...replay);
481
+ }
482
+ else if (!anchorSeen) {
483
+ // A different user/runtime continuation belongs to stale queued work;
484
+ // anything before it should not be attributed to the next real user turn.
485
+ preAnchorEvents = [];
486
+ }
487
+ continue;
488
+ }
489
+ if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
490
+ bucket.splice(i, 1);
491
+ preAnchorEvents.push(ev);
492
+ if (preAnchorEvents.length > 200)
493
+ preAnchorEvents.shift();
494
+ continue;
495
+ }
496
+ let matchesSession = false;
497
+ if (eventSessionMatches && anchorSeen) {
498
+ if (!expectedUserText) {
499
+ // Backward-compatible mode for tests/internal callers that do not
500
+ // provide an anchor: allow session-key matching as before.
501
+ matchesSession = true;
502
+ }
503
+ else if (!evRunId) {
504
+ matchesSession = true;
505
+ }
506
+ else if (activeRunIds.has(evRunId)) {
507
+ matchesSession = true;
508
+ }
509
+ else if (ev.stream === "lifecycle" && ev.data?.phase === "start") {
510
+ activeRunIds.add(evRunId);
511
+ matchesSession = true;
512
+ }
354
513
  }
355
- else {
356
- i++;
514
+ if (!(matchesRun || matchesSession)) {
515
+ // When collectReply is anchored to a specific user message, discard stale
516
+ // session events that arrive before that anchor (or from unrelated runIds)
517
+ // so replayInvalid/aborted events from older runtime continuations cannot
518
+ // be misattributed to the current user turn.
519
+ if (eventSessionMatches && expectedUserText) {
520
+ bucket.splice(i, 1);
521
+ }
522
+ else {
523
+ i++;
524
+ }
357
525
  continue;
358
526
  }
359
527
  bucket.splice(i, 1);
@@ -361,12 +529,16 @@ export class OpenClawClient {
361
529
  // means the agent is still alive. Use an idle timeout, not an absolute
362
530
  // wall-clock timeout, so long tool-heavy tasks are not killed while active.
363
531
  resetIdleTimer();
532
+ rememberActivity(ev);
364
533
  // If more events arrive after a replay-invalid lifecycle end, that lifecycle
365
534
  // was not terminal for the user-visible run. Keep waiting for the real final.
366
535
  if (replayInvalidTimer) {
367
536
  clearTimeout(replayInvalidTimer);
368
537
  replayInvalidTimer = null;
369
538
  }
539
+ if (ev.stream !== "lifecycle") {
540
+ pendingRuntimeFailureText = "";
541
+ }
370
542
  if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
371
543
  lifecycleStartedLogged = true;
372
544
  console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
@@ -433,13 +605,10 @@ export class OpenClawClient {
433
605
  return;
434
606
  }
435
607
  if (!latestFinalText) {
436
- const state = ev.data?.livenessState || "unknown";
437
- const reason = ev.data?.stopReason || "";
438
- const replayInvalid = ev.data?.replayInvalid ? ", replayInvalid" : "";
439
- const failureText = `⚠️ Agent 未正常完成\n状态: ${state}${replayInvalid}${reason ? "\n原因: " + reason : ""}\n请重试,或用 /reset 重置会话`;
608
+ const failureText = buildFailureText(ev);
440
609
  if (ev.data?.replayInvalid) {
441
- console.warn(`[OpenClaw] replayInvalid lifecycle observed for runId=${evRunId || runId}; waiting for subsequent events before surfacing failure`);
442
- replayInvalidTimer = setTimeout(() => finish(failureText), 120000);
610
+ pendingRuntimeFailureText = failureText;
611
+ console.warn(`[OpenClaw] replayInvalid lifecycle observed for runId=${evRunId || runId}; waiting for real text or idle timeout`);
443
612
  return;
444
613
  }
445
614
  if (ev.data?.livenessState !== "working") {
@@ -453,7 +622,7 @@ export class OpenClawClient {
453
622
  };
454
623
  // If lifecycle end beats chat final, a short delta like "N" can be a truncated
455
624
  // final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
456
- if (!options?.emptyFinalAsNoReply && !chatFinalText && text.length <= 1) {
625
+ if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1) {
457
626
  lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
458
627
  }
459
628
  else {
@@ -679,7 +848,7 @@ export class OpenClawClient {
679
848
  idempotencyKey: randomUUID(),
680
849
  });
681
850
  console.log(`[OpenClaw] chat.send runId: ${result.runId} (rpc=${Date.now() - sendStartedAt}ms, attachments=${params.attachments?.length || 0})`);
682
- return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply });
851
+ return await this.collectReply(result.runId, params.timeoutMs || 1800000, sk, { emptyFinalAsNoReply: params.emptyFinalAsNoReply, expectedUserText: params.message });
683
852
  }
684
853
  finally {
685
854
  // OpenClaw can emit the final assistant session.message a moment after
@@ -698,7 +867,7 @@ export class OpenClawClient {
698
867
  return "";
699
868
  return `
700
869
 
701
- [Bridge attachment capability hint: This is an OpenClaw Lark Multi-Agent bridge session. You cannot send Feishu files/images directly from inside OpenClaw. Do NOT call message, sessions_send, Feishu tools, or proactive send tools for this request. If the user asks you to send an image/file/document to Feishu, prefer creating new files under ${BRIDGE_ATTACHMENTS_DIR}/; existing files under the OpenClaw workspace are also allowed. Include this exact marker at the very end of your final reply (do not explain or expose the marker as normal text): <LMA_BRIDGE_ATTACHMENTS>{"attachments":[{"type":"image|file|document","path":"/absolute/path","caption":"optional"}]}</LMA_BRIDGE_ATTACHMENTS>. The bridge layer will parse this marker and send the attachment. Use type=image for images; use type=document for Markdown documents (.md) so the bridge creates a Feishu cloud document and sends its link; use type=file for other ordinary files.]`;
870
+ [Bridge attachment capability hint: This is an OpenClaw Lark Multi-Agent bridge session. You cannot send Feishu files/images directly from inside OpenClaw. Do NOT call message, sessions_send, Feishu tools, or proactive send tools for this request. If the user asks you to send an image/file/document to Feishu, save or copy the real file under ${BRIDGE_ATTACHMENTS_DIR}/, or use an existing real file under the OpenClaw workspace. NEVER use placeholder paths such as /absolute/path, /real/path, /path/to/file, or example paths; the path must be the actual file you created and it must exist. Include this exact marker at the very end of your final reply (do not explain or expose the marker as normal text): <LMA_BRIDGE_ATTACHMENTS>{"attachments":[{"type":"image","path":"${BRIDGE_ATTACHMENTS_DIR}/replace-with-actual-created-file.png","caption":"optional"}]}</LMA_BRIDGE_ATTACHMENTS>. Replace the example path with the actual existing file path before replying. The bridge layer will parse this marker and send the attachment. Use type=image for images; use type=document for Markdown documents (.md) so the bridge creates a Feishu cloud document and sends its link; use type=file for other ordinary files.]`;
702
871
  }
703
872
  /**
704
873
  * Build and send a context catch-up message followed by the actual message.
@@ -733,20 +902,30 @@ export class OpenClawClient {
733
902
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
734
903
  });
735
904
  }
736
- // Build context block + actual message in one chat.send
737
- const contextLines = params.unsyncedMessages.map((m) => {
738
- const time = new Date(m.timestamp).toLocaleTimeString("zh-CN", {
739
- hour: "2-digit",
740
- minute: "2-digit",
741
- hour12: false,
742
- });
743
- const tag = m.senderType === "bot" ? `${m.senderName} (AI)` : m.senderName;
744
- return `[${tag} ${time}]: ${m.content}`;
745
- });
746
- const combined = `[以下是你不在线期间群里的对话,请了解上下文]\n` +
747
- contextLines.join("\n") +
748
- `\n---\n` +
749
- `[${params.currentSenderName}]: ${params.currentMessage}`;
905
+ // Build context block + actual message in one chat.send, or write the
906
+ // full context to a local transcript file when it is too large.
907
+ const contextLines = this.formatContextLines(params.unsyncedMessages);
908
+ const inlineContext = contextLines.join("\n");
909
+ const inlineBytes = Buffer.byteLength(inlineContext, "utf8");
910
+ const useFileContext = params.unsyncedMessages.length > MAX_INLINE_CONTEXT_MESSAGES || inlineBytes > MAX_INLINE_CONTEXT_BYTES;
911
+ let combined;
912
+ if (useFileContext) {
913
+ const filePath = this.writeContextSyncFile(params.sessionKey, params.unsyncedMessages, contextLines);
914
+ combined =
915
+ `[以下是你不在线期间群里的长历史上下文,因消息数或大小超过直接内联阈值,已写入本地文件]\n` +
916
+ `文件路径:${filePath}\n\n` +
917
+ `你必须先使用 read 工具读取这个文件,理解其中的完整群聊历史,再回答当前消息。\n` +
918
+ `如果无法读取文件,请明确说明,不能直接忽略历史上下文作答。\n` +
919
+ `\n---\n` +
920
+ `[${params.currentSenderName}]: ${params.currentMessage}`;
921
+ }
922
+ else {
923
+ combined =
924
+ `[以下是你不在线期间群里的对话,请了解上下文]\n` +
925
+ inlineContext +
926
+ `\n---\n` +
927
+ `[${params.currentSenderName}]: ${params.currentMessage}`;
928
+ }
750
929
  return this.chatSend({
751
930
  sessionKey: params.sessionKey,
752
931
  message: combined + mediaInstruction + bridgeAttachmentHint,
@@ -756,6 +935,42 @@ export class OpenClawClient {
756
935
  emptyFinalAsNoReply: params.emptyFinalAsNoReply,
757
936
  });
758
937
  }
938
+ formatContextLines(messages) {
939
+ return messages.map((m) => {
940
+ const time = new Date(m.timestamp).toLocaleString("zh-CN", {
941
+ month: "2-digit",
942
+ day: "2-digit",
943
+ hour: "2-digit",
944
+ minute: "2-digit",
945
+ hour12: false,
946
+ });
947
+ const tag = m.senderType === "bot" ? `${m.senderName} (AI)` : m.senderName;
948
+ return `[${tag} ${time}]: ${m.content}`;
949
+ });
950
+ }
951
+ writeContextSyncFile(sessionKey, messages, contextLines) {
952
+ const safeSessionKey = sessionKey.replace(/[^a-zA-Z0-9_.-]+/g, "_");
953
+ const dir = join(CONTEXT_SYNC_DIR, safeSessionKey);
954
+ mkdirSync(dir, { recursive: true });
955
+ const filePath = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}.md`);
956
+ const first = messages[0];
957
+ const last = messages[messages.length - 1];
958
+ const markdown = [
959
+ `# LMA 群聊历史上下文同步`,
960
+ ``,
961
+ `- Session: ${sessionKey}`,
962
+ `- Messages: ${messages.length}`,
963
+ `- Range: ${first?.id ?? "?"} → ${last?.id ?? "?"}`,
964
+ `- Generated: ${new Date().toISOString()}`,
965
+ ``,
966
+ `## Messages`,
967
+ ``,
968
+ contextLines.join("\n\n"),
969
+ ``,
970
+ ].join("\n");
971
+ writeFileSync(filePath, markdown, "utf8");
972
+ return filePath;
973
+ }
759
974
  extractImageAttachments(contents) {
760
975
  const attachments = [];
761
976
  const seen = new Set();
package/dist/paths.js CHANGED
@@ -14,7 +14,7 @@ export function getOpenClawWorkspaceDir() {
14
14
  return resolve(process.env.OPENCLAW_WORKSPACE_DIR || resolve(homedir(), ".openclaw/workspace"));
15
15
  }
16
16
  export function getBridgeAttachmentAllowedRoots() {
17
- const roots = [getBridgeAttachmentsDir(), getOpenClawWorkspaceDir()];
17
+ const roots = [getBridgeAttachmentsDir(), getOpenClawWorkspaceDir(), resolve(getStateDir(), "data", "media")];
18
18
  const extra = process.env.OPENCLAW_LARK_MULTI_AGENT_ATTACHMENT_ALLOW_ROOTS || "";
19
19
  for (const part of extra.split(",")) {
20
20
  const trimmed = part.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Multi-bot Lark/Feishu bridge for OpenClaw, with per-bot model routing and isolated sessions",
5
5
  "type": "module",
6
6
  "scripts": {