openclaw-lark-multi-agent 1.0.5 → 1.0.7

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.
@@ -102,6 +102,7 @@ export declare class FeishuBot {
102
102
  private inferFeishuFileType;
103
103
  private isImagePath;
104
104
  private errorSummary;
105
+ private stripReadOnlyDocxFields;
105
106
  private createFeishuDocFromMarkdown;
106
107
  private sendBridgeAttachment;
107
108
  private sendBridgeFileAttachment;
@@ -637,25 +637,27 @@ export class FeishuBot {
637
637
  }
638
638
  async processQueueInner(chatId) {
639
639
  while (true) {
640
- const unsyncedMessages = this.store.getUnsyncedMessages(this.config.name, chatId);
641
640
  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
641
  const pendingTriggerIds = this.store.getPendingTriggerIds(this.config.name, chatId);
649
642
  // 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) {
643
+ const pendingHumanTriggers = pendingMessages
644
+ .filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id))
645
+ .sort((a, b) => a.timestamp - b.timestamp);
646
+ if (pendingHumanTriggers.length === 0) {
653
647
  break;
654
648
  }
655
649
  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];
650
+ // The last trigger message is the "current" one, everything else is context.
651
+ const lastHuman = pendingHumanTriggers[pendingHumanTriggers.length - 1];
658
652
  const triggerId = lastHuman.id || 0;
653
+ const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
654
+ const messageById = new Map();
655
+ for (const msg of [...catchupMessages, ...pendingMessages]) {
656
+ if (msg.id)
657
+ messageById.set(msg.id, msg);
658
+ }
659
+ const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
660
+ const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
659
661
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
660
662
  console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
661
663
  this.store.clearPendingTriggers(this.config.name, chatId, triggerId);
@@ -716,6 +718,8 @@ export class FeishuBot {
716
718
  // must remain pending for the next loop.
717
719
  const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
718
720
  const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
721
+ const syncBatchId = `${this.config.name}:${chatId}:${triggerId}:${Date.now()}`;
722
+ this.store.markMessagesSynced(this.config.name, chatId, allUnsynced.map((m) => m.id || 0), syncBatchId);
719
723
  this.store.markSynced(this.config.name, chatId, maxId);
720
724
  this.store.clearPendingTriggers(this.config.name, chatId, maxId);
721
725
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
@@ -739,8 +743,10 @@ export class FeishuBot {
739
743
  // Do not advance sync past a human message that arrived while this
740
744
  // run was busy. Otherwise the pending trigger remains in the table
741
745
  // but getUnsyncedMessages() can no longer see it.
742
- if (!hasEarlierPending)
746
+ if (!hasEarlierPending) {
747
+ this.store.markMessagesSynced(this.config.name, chatId, [replyId], `${this.config.name}:${chatId}:reply:${replyId}`);
743
748
  this.store.markSynced(this.config.name, chatId, replyId);
749
+ }
744
750
  }
745
751
  }
746
752
  // Wait for all pending tool event messages to be delivered first
@@ -1227,6 +1233,20 @@ export class FeishuBot {
1227
1233
  const msg = data?.msg || data?.message || err?.message || String(err);
1228
1234
  return `${code}${msg}`.slice(0, 800);
1229
1235
  }
1236
+ stripReadOnlyDocxFields(value) {
1237
+ if (Array.isArray(value))
1238
+ return value.map((item) => this.stripReadOnlyDocxFields(item));
1239
+ if (value && typeof value === "object") {
1240
+ const out = {};
1241
+ for (const [key, child] of Object.entries(value)) {
1242
+ if (key === "merge_info")
1243
+ continue;
1244
+ out[key] = this.stripReadOnlyDocxFields(child);
1245
+ }
1246
+ return out;
1247
+ }
1248
+ return value;
1249
+ }
1230
1250
  async createFeishuDocFromMarkdown(filePath) {
1231
1251
  const rawTitle = basename(filePath).replace(/\.[^.]+$/, "").trim() || "Markdown Document";
1232
1252
  const markdown = readFileSync(filePath, "utf8");
@@ -1238,7 +1258,7 @@ export class FeishuBot {
1238
1258
  throw new Error(`Feishu doc create returned no document_id for ${filePath}`);
1239
1259
  const converted = await docx.document.convert({ data: { content_type: "markdown", content: markdown } });
1240
1260
  const convertedData = converted?.data || converted;
1241
- const blocks = convertedData?.blocks || [];
1261
+ const blocks = this.stripReadOnlyDocxFields(convertedData?.blocks || []);
1242
1262
  const firstLevelBlockIds = convertedData?.first_level_block_ids || [];
1243
1263
  if (Array.isArray(blocks) && blocks.length > 0) {
1244
1264
  await docx.documentBlockDescendant.create({
@@ -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
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
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": {