openclaw-lark-multi-agent 1.0.16 → 1.0.17

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.
@@ -396,7 +396,8 @@ export class FeishuBot {
396
396
  const commandText = this.stripLeadingCommandMentions(trimmedCleanText);
397
397
  // Escape hatch: //command means send /command through to OpenClaw,
398
398
  // while /command remains a bridge-level openclaw-lark-multi-agent command.
399
- if (commandText.startsWith("//")) {
399
+ const isNativeOpenClawCommand = commandText.startsWith("//");
400
+ if (isNativeOpenClawCommand) {
400
401
  cleanText = "/" + commandText.slice(2).trimStart();
401
402
  }
402
403
  else if (commandText.startsWith("/")) {
@@ -413,6 +414,7 @@ export class FeishuBot {
413
414
  senderName,
414
415
  content: cleanText,
415
416
  timestamp: Date.now(),
417
+ triggerKind: isNativeOpenClawCommand ? "native_command" : "normal",
416
418
  });
417
419
  if (insertedId < 0)
418
420
  insertedId = this.store.getMessageId(messageId) || -1;
@@ -727,26 +729,64 @@ export class FeishuBot {
727
729
  break;
728
730
  }
729
731
  this.busyChats.set(chatId, Date.now());
730
- // The last trigger message is the "current" one, everything else is context.
731
- const lastHuman = pendingHumanTriggers[pendingHumanTriggers.length - 1];
732
- const triggerId = lastHuman.id || 0;
733
- const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
734
- const messageById = new Map();
735
- for (const msg of [...catchupMessages, ...pendingMessages]) {
736
- if (msg.id)
737
- messageById.set(msg.id, msg);
732
+ // Pick the batch to process this loop. Consecutive plain human messages are
733
+ // merged into a single run; native escaped commands (//x) are processed on
734
+ // their own and never merged with ordinary messages, because they are exact
735
+ // pass-through requests that must not receive catch-up context.
736
+ const firstPending = pendingHumanTriggers[0];
737
+ const firstIsNative = firstPending.triggerKind === "native_command";
738
+ let mergedTriggers;
739
+ if (firstIsNative) {
740
+ // One native command at a time.
741
+ mergedTriggers = [firstPending];
742
+ }
743
+ else {
744
+ // Take the leading run of consecutive non-native messages.
745
+ mergedTriggers = [];
746
+ for (const m of pendingHumanTriggers) {
747
+ if (m.triggerKind === "native_command")
748
+ break;
749
+ mergedTriggers.push(m);
750
+ }
738
751
  }
739
- const allUnsynced = Array.from(messageById.values()).sort((a, b) => a.timestamp - b.timestamp);
740
- const humanUnsynced = allUnsynced.filter((m) => m.senderType === "human" && m.id && pendingTriggerIds.has(m.id));
752
+ const isNativeCommandTrigger = firstIsNative;
753
+ const lastHuman = mergedTriggers[mergedTriggers.length - 1];
754
+ const triggerId = lastHuman.id || 0;
755
+ const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
756
+ const mergedTriggerIds = mergedTriggers.map((m) => m.id || 0).filter(Boolean);
757
+ const mergedTriggerIdSet = new Set(mergedTriggerIds);
758
+ // Catch-up is only injected in group chats. p2p must never get it.
759
+ // Use "not p2p" rather than "=== group": chatInfo is always cached before
760
+ // a message reaches the queue, but if chat_type were ever missing we'd
761
+ // rather treat it as a group (catch-up helps in groups, harms in p2p).
762
+ const chatType = this.store.getChatInfo(chatId)?.chatType || "";
763
+ const isGroup = chatType !== "p2p";
741
764
  if (triggerId && this.store.hasDeliveredReply(this.config.name, chatId, triggerId)) {
742
765
  console.warn(`[${this.config.name}] Duplicate trigger skipped for ${chatId.slice(-8)} msgId=${triggerId}`);
743
- this.store.clearPendingTriggers(this.config.name, chatId, triggerId);
766
+ for (const id of mergedTriggerIds)
767
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
744
768
  continue;
745
769
  }
746
- const contextMsgs = allUnsynced.filter((m) => m.id !== lastHuman.id);
770
+ // Catch-up context: messages this bot has not seen yet in this GROUP chat.
771
+ // Includes both human and other-bot messages (so mention-only replies can
772
+ // see the human message they refer to). Excludes:
773
+ // - the merged trigger messages themselves (they are the current input)
774
+ // - other pending triggers (each is current for its own run)
775
+ // - this bot's own messages
776
+ // - native escaped commands
777
+ // p2p never injects catch-up; native command runs never inject catch-up.
778
+ let contextMsgs = [];
779
+ if (isGroup && !isNativeCommandTrigger) {
780
+ const catchupMessages = this.store.getUnsyncedMessagesForBot(this.config.name, chatId, triggerId);
781
+ contextMsgs = catchupMessages.filter((m) => m.senderName !== this.config.name
782
+ && !(m.id && mergedTriggerIdSet.has(m.id))
783
+ && !(m.id && pendingTriggerIds.has(m.id))
784
+ && m.triggerKind !== "native_command");
785
+ }
786
+ const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
747
787
  const queueStartedAt = Date.now();
748
788
  const sessionKey = await this.ensureSession(chatId);
749
- console.log(`[${this.config.name}] Sending ${humanUnsynced.length} trigger(s) to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length})`);
789
+ console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length})`);
750
790
  // Update reactions: queued messages → sent (GET/了解)
751
791
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
752
792
  for (const ack of pendingAcks) {
@@ -763,12 +803,14 @@ export class FeishuBot {
763
803
  reply = await this.openclawClient.chatSendWithContext({
764
804
  sessionKey,
765
805
  unsyncedMessages: contextMsgs,
766
- currentMessage: lastHuman.content,
806
+ currentMessage: mergedContent,
767
807
  currentSenderName: lastHuman.senderName,
768
808
  deliver: false,
769
809
  // Keep bridge UX responsive; long agent/tool loops should surface a clear failure
770
810
  // instead of leaving reactions stuck forever.
771
811
  timeoutMs: 1_800_000,
812
+ includeContext: !isNativeCommandTrigger,
813
+ includeBridgeAttachmentHint: !isNativeCommandTrigger,
772
814
  });
773
815
  }
774
816
  finally {
@@ -796,12 +838,12 @@ export class FeishuBot {
796
838
  // arrive while the agent is busy may already be in pendingAckMessages,
797
839
  // but they are not part of allUnsynced/humanUnsynced for this run and
798
840
  // must remain pending for the next loop.
799
- const maxId = Math.max(...allUnsynced.map((m) => m.id || 0));
800
- const processedTriggerIds = new Set(humanUnsynced.map((m) => m.id || 0).filter(Boolean));
841
+ const maxId = Math.max(...processedMessages.map((m) => m.id || 0));
801
842
  const syncBatchId = `${this.config.name}:${chatId}:${triggerId}:${Date.now()}`;
802
- this.store.markMessagesSynced(this.config.name, chatId, allUnsynced.map((m) => m.id || 0), syncBatchId);
843
+ this.store.markMessagesSynced(this.config.name, chatId, processedMessages.map((m) => m.id || 0), syncBatchId);
803
844
  this.store.markSynced(this.config.name, chatId, maxId);
804
- this.store.clearPendingTriggers(this.config.name, chatId, maxId);
845
+ for (const id of mergedTriggerIds)
846
+ this.store.clearPendingTrigger(this.config.name, chatId, id);
805
847
  const shouldReply = trimmedReply.length > 0 && !explicitNoReply;
806
848
  const isRuntimeFailure = shouldReply && this.isRuntimeFailureText(trimmedReply);
807
849
  // Record bot reply only if it is user-visible/context-worthy.
@@ -844,8 +886,10 @@ export class FeishuBot {
844
886
  else {
845
887
  try {
846
888
  await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`);
847
- if (triggerId)
848
- this.store.markDeliveredReply(this.config.name, chatId, triggerId, lastHuman.messageId);
889
+ // Mark every merged trigger as delivered so retries/restarts do
890
+ // not re-process any of them.
891
+ for (const id of mergedTriggerIds)
892
+ this.store.markDeliveredReply(this.config.name, chatId, id, lastHuman.messageId);
849
893
  }
850
894
  catch (err) {
851
895
  // enqueueAndDispatchDelivery already sent a user-visible delivery
@@ -865,7 +909,7 @@ export class FeishuBot {
865
909
  const pendingAcks = this.pendingAckMessages.get(chatId) || [];
866
910
  const remainingAcks = [];
867
911
  for (const ack of pendingAcks) {
868
- if (processedTriggerIds.has(ack.rowId)) {
912
+ if (mergedTriggerIdSet.has(ack.rowId)) {
869
913
  await this.removeReaction(ack.messageId, ack.emoji).catch(() => { });
870
914
  await this.addReaction(ack.messageId, "DONE").catch(() => { });
871
915
  }
package/dist/i18n.js CHANGED
@@ -13,7 +13,8 @@ const dict = {
13
13
  "[LMA bridge policy]",
14
14
  "你正在 OpenClaw Lark Multi-Agent bridge 会话中。",
15
15
  "不要调用 message、sessions_send、feishu_im_user_message 或任何主动向飞书/外部聊天发送消息的工具。",
16
- "直接在当前回复中作答;LMA bridge 会负责把最终回复投递回原始飞书群。",
16
+ "直接在当前回复中作答;LMA bridge 会负责把最终回复投递回原始飞书会话。",
17
+ "如果你是某个群的 Chairman:Chairman 的主持、调停、质疑和总结职责只在 /discuss 模式中生效。非 /discuss 模式下,即使你是 Chairman,也只按普通 @all/free/定向参与者身份给出自己的观点,不要总结、主持、调停、质疑或收束其他 bot 的回复。",
17
18
  ].join("\n"),
18
19
  discussParticipantPrompt: (p) => [
19
20
  "这是一个多智能体结构化讨论。",
@@ -77,6 +78,7 @@ const dict = {
77
78
  "You are in an OpenClaw Lark Multi-Agent bridge session.",
78
79
  "Do not call message, sessions_send, feishu_im_user_message, or any proactive external-chat sending tool.",
79
80
  "Reply directly in the current assistant response; the LMA bridge will deliver the final reply back to the original Feishu chat.",
81
+ "If you are the Chairman of a group: your chairman duties (moderating, challenging, summarizing, or concluding other bots' replies) apply only inside /discuss mode. Outside /discuss mode, even if you are the Chairman, answer only as a normal @all/free/direct participant with your own view; do not summarize, moderate, challenge, or conclude other bots' replies.",
80
82
  ].join("\n"),
81
83
  discussParticipantPrompt: (p) => [
82
84
  "This is a structured multi-agent discussion.",
@@ -27,6 +27,7 @@ export interface ChatMessage {
27
27
  senderName: string;
28
28
  content: string;
29
29
  timestamp: number;
30
+ triggerKind?: "normal" | "native_command";
30
31
  }
31
32
  export interface DeliveryOutboxItem {
32
33
  id?: number;
@@ -20,7 +20,8 @@ export class MessageStore {
20
20
  sender_type TEXT NOT NULL,
21
21
  sender_name TEXT NOT NULL,
22
22
  content TEXT NOT NULL,
23
- timestamp INTEGER NOT NULL
23
+ timestamp INTEGER NOT NULL,
24
+ trigger_kind TEXT NOT NULL DEFAULT 'normal'
24
25
  );
25
26
  CREATE INDEX IF NOT EXISTS idx_messages_chat_ts ON messages(chat_id, timestamp);
26
27
  CREATE INDEX IF NOT EXISTS idx_messages_chat_id ON messages(chat_id, id);
@@ -220,6 +221,13 @@ export class MessageStore {
220
221
  catch {
221
222
  // Column already exists
222
223
  }
224
+ // Migration: add trigger_kind column if missing
225
+ try {
226
+ this.db.exec(`ALTER TABLE messages ADD COLUMN trigger_kind TEXT NOT NULL DEFAULT 'normal'`);
227
+ }
228
+ catch {
229
+ // Column already exists
230
+ }
223
231
  // Migration: add free_discussion column if missing
224
232
  try {
225
233
  this.db.exec(`ALTER TABLE chat_info ADD COLUMN free_discussion INTEGER NOT NULL DEFAULT 0`);
@@ -253,9 +261,9 @@ export class MessageStore {
253
261
  insert(msg) {
254
262
  try {
255
263
  const result = this.db.prepare(`
256
- INSERT INTO messages (chat_id, message_id, sender_type, sender_name, content, timestamp)
257
- VALUES (?, ?, ?, ?, ?, ?)
258
- `).run(msg.chatId, msg.messageId, msg.senderType, msg.senderName, msg.content, msg.timestamp);
264
+ INSERT INTO messages (chat_id, message_id, sender_type, sender_name, content, timestamp, trigger_kind)
265
+ VALUES (?, ?, ?, ?, ?, ?, ?)
266
+ `).run(msg.chatId, msg.messageId, msg.senderType, msg.senderName, msg.content, msg.timestamp, msg.triggerKind || "normal");
259
267
  return Number(result.lastInsertRowid);
260
268
  }
261
269
  catch (err) {
@@ -309,6 +317,7 @@ export class MessageStore {
309
317
  senderName: r.sender_name,
310
318
  content: r.content,
311
319
  timestamp: r.timestamp,
320
+ triggerKind: r.trigger_kind || "normal",
312
321
  }));
313
322
  }
314
323
  clearPendingTriggers(botName, chatId, upToId) {
@@ -459,6 +468,7 @@ export class MessageStore {
459
468
  senderName: r.sender_name,
460
469
  content: r.content,
461
470
  timestamp: r.timestamp,
471
+ triggerKind: r.trigger_kind || "normal",
462
472
  }));
463
473
  }
464
474
  /**
@@ -486,6 +496,7 @@ export class MessageStore {
486
496
  senderName: r.sender_name,
487
497
  content: r.content,
488
498
  timestamp: r.timestamp,
499
+ triggerKind: r.trigger_kind || "normal",
489
500
  }));
490
501
  }
491
502
  markMessagesSynced(botName, chatId, messageRowIds, syncBatchId = '') {
@@ -531,6 +542,7 @@ export class MessageStore {
531
542
  senderName: r.sender_name,
532
543
  content: r.content,
533
544
  timestamp: r.timestamp,
545
+ triggerKind: r.trigger_kind || "normal",
534
546
  }));
535
547
  }
536
548
  /**
@@ -144,6 +144,10 @@ export declare class OpenClawClient {
144
144
  deliver?: boolean;
145
145
  timeoutMs?: number;
146
146
  emptyFinalAsNoReply?: boolean;
147
+ /** Native escaped commands (//status -> /status) should not receive catch-up context. */
148
+ includeContext?: boolean;
149
+ /** Disable bridge attachment hints for native commands and other exact pass-through messages. */
150
+ includeBridgeAttachmentHint?: boolean;
147
151
  }): Promise<string>;
148
152
  private formatContextLines;
149
153
  private writeContextSyncFile;
@@ -955,7 +955,11 @@ export class OpenClawClient {
955
955
  }
956
956
  }
957
957
  shouldInjectBridgeAttachmentHint(text) {
958
- return /发送|发到|发给|传|上传|附件|文件|文档|图片|图像|照片|生成图|做张图|画张图|导出|保存|pdf|docx?|xlsx?|pptx?|markdown|\bmd\b/i.test(text);
958
+ // Require an action word combined with an artifact word, so ordinary talk
959
+ // that merely mentions "文档/图片/投递" does not trigger the long hint.
960
+ const action = /(发送|发到|发给|发一[张份个]|上传|生成|做一?[张份个]|画一?[张份个]|创建|导出|保存|附上|附件形式|attach|upload|export|generate|create|save)/i;
961
+ const artifact = /(图片|图像|照片|配图|海报|封面|文件|文档|附件|表格|pdf|docx?|xlsx?|pptx?|markdown|\bmd\b|\.png|\.jpe?g|\.gif|\.webp|image|file|document)/i;
962
+ return action.test(text) && artifact.test(text);
959
963
  }
960
964
  bridgeAttachmentHint(text) {
961
965
  if (!this.shouldInjectBridgeAttachmentHint(text))
@@ -978,15 +982,19 @@ export class OpenClawClient {
978
982
  * If there are no unsynced messages, just sends the actual message directly.
979
983
  */
980
984
  async chatSendWithContext(params) {
985
+ const includeContext = params.includeContext !== false;
986
+ const includeBridgeAttachmentHint = params.includeBridgeAttachmentHint !== false;
987
+ const contextForAttachments = includeContext ? params.unsyncedMessages : [];
981
988
  const attachments = this.extractImageAttachments([
982
- ...params.unsyncedMessages.map((m) => m.content),
989
+ ...contextForAttachments.map((m) => m.content),
983
990
  params.currentMessage,
984
991
  ]);
985
992
  const mediaInstruction = attachments.length > 0
986
993
  ? "\n\n[Media note: Image attachments are included with this message. If your model can inspect images directly, use the attached image input. If it cannot, use the image tool on the provided media/attachment path; do not try unrelated network or model-provider workarounds.]"
987
994
  : "";
988
- const bridgeAttachmentHint = this.bridgeAttachmentHint(params.currentMessage);
989
- if (params.unsyncedMessages.length === 0) {
995
+ const bridgeAttachmentHint = includeBridgeAttachmentHint ? this.bridgeAttachmentHint(params.currentMessage) : "";
996
+ const unsyncedMessages = includeContext ? params.unsyncedMessages : [];
997
+ if (unsyncedMessages.length === 0) {
990
998
  // No context to catch up, send directly
991
999
  return this.chatSend({
992
1000
  sessionKey: params.sessionKey,
@@ -999,24 +1007,24 @@ export class OpenClawClient {
999
1007
  }
1000
1008
  // Build context block + actual message in one chat.send, or write the
1001
1009
  // full context to a local transcript file when it is too large.
1002
- const contextLines = this.formatContextLines(params.unsyncedMessages);
1010
+ const contextLines = this.formatContextLines(unsyncedMessages);
1003
1011
  const inlineContext = contextLines.join("\n");
1004
1012
  const inlineBytes = Buffer.byteLength(inlineContext, "utf8");
1005
- const useFileContext = params.unsyncedMessages.length > MAX_INLINE_CONTEXT_MESSAGES || inlineBytes > MAX_INLINE_CONTEXT_BYTES;
1013
+ const useFileContext = unsyncedMessages.length > MAX_INLINE_CONTEXT_MESSAGES || inlineBytes > MAX_INLINE_CONTEXT_BYTES;
1006
1014
  let combined;
1007
1015
  if (useFileContext) {
1008
- const filePath = this.writeContextSyncFile(params.sessionKey, params.unsyncedMessages, contextLines);
1016
+ const filePath = this.writeContextSyncFile(params.sessionKey, unsyncedMessages, contextLines);
1009
1017
  combined =
1010
- `[以下是你不在线期间群里的长历史上下文,因消息数或大小超过直接内联阈值,已写入本地文件]\n` +
1018
+ `[以下是此前未同步的长历史对话上下文,因消息数或大小超过直接内联阈值,已写入本地文件]\n` +
1011
1019
  `文件路径:${filePath}\n\n` +
1012
- `你必须先使用 read 工具读取这个文件,理解其中的完整群聊历史,再回答当前消息。\n` +
1020
+ `你必须先使用 read 工具读取这个文件,理解其中的完整历史对话,再回答当前消息。\n` +
1013
1021
  `如果无法读取文件,请明确说明,不能直接忽略历史上下文作答。\n` +
1014
1022
  `\n---\n` +
1015
1023
  `[${params.currentSenderName}]: ${params.currentMessage}`;
1016
1024
  }
1017
1025
  else {
1018
1026
  combined =
1019
- `[以下是你不在线期间群里的对话,请了解上下文]\n` +
1027
+ `[以下是群里其他成员刚发、你还没看到的发言,供参考]\n` +
1020
1028
  inlineContext +
1021
1029
  `\n---\n` +
1022
1030
  `[${params.currentSenderName}]: ${params.currentMessage}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.0.16",
3
+ "version": "1.0.17",
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": {