openclaw-lark-multi-agent 1.4.3 → 1.4.4

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.
package/README.md CHANGED
@@ -37,7 +37,7 @@ All of them connect to the same OpenClaw Gateway while keeping sessions, queues,
37
37
  - Feishu image download and OpenClaw multimodal attachment forwarding
38
38
  - Bridge attachment marker protocol for generated files/images/documents
39
39
  - Feishu CardKit v2 Markdown rendering, including native table elements for pipe tables
40
- - Live status card: in non-verbose mode each run shows a single self-updating Feishu interactive card (title + recent activity window with per-line timestamps + elapsed/model footer, refreshed once per second), which collapses to a compact one-line summary on a clean finish and retains the recent activity on failure for debugging
40
+ - Live status card: in non-verbose mode each run shows a self-updating Feishu interactive card (title + recent activity window with per-line timestamps + elapsed footer, refreshed once per second), which collapses to tool-call count + elapsed on a clean finish; the final answer is sent separately at the bottom with a model footer
41
41
  - Bridge-level slash commands and escaped OpenClaw slash commands
42
42
  - `/discuss` mode for barrier-style multi-bot group discussion, including per-round markers and no-reply status notices
43
43
  - `/chairman` role: a single per-group chairman that answers plain messages when no bot is in Free mode, and acts as host, challenger, and summarizer inside `/discuss`
@@ -463,7 +463,7 @@ While running, the card shows:
463
463
  - a rolling window of the most recent activity lines (tool start `▸`, tool end
464
464
  `✓`, and intermediate assistant text `•`), each prefixed with the relative
465
465
  time `mm:ss` since the run started;
466
- - a footer with elapsed time and the bound model name.
466
+ - a footer with elapsed time.
467
467
 
468
468
  The card is created lazily (a fast reply that finishes within the create delay
469
469
  never spawns a card, to avoid flicker) and is updated with `im.message.patch`,
@@ -479,8 +479,9 @@ When the run ends:
479
479
  header, so the steps leading up to the failure stay visible for debugging.
480
480
 
481
481
  The final answer is always delivered separately through the normal interactive-card
482
- delivery path, so Markdown renders correctly; the live status card is a distinct
483
- message and never replaces or blocks the final reply.
482
+ delivery path, so it is created at completion time and remains below any user messages
483
+ inserted during the run. Its footer shows only the bound model name. The live-status
484
+ card remains a distinct earlier message and owns tool-call count + elapsed time.
484
485
 
485
486
  Live status is on by default and can be toggled per bot/chat with
486
487
  `/livestatus [on|off]`. It can be disabled globally by setting
package/README.zh-CN.md CHANGED
@@ -408,6 +408,12 @@ v1 行为边界:如果消息已经进入 OpenClaw 正在处理,暂不 abort
408
408
 
409
409
  如果 Agent 需要发送生成的文件、图片或文档,应使用 bridge attachment marker 协议,而不是直接调用飞书消息工具。桥接层会从可见回复中剥离 marker,校验文件路径位于配置的附件目录下,然后上传/发送附件,并写入本地上下文。Markdown 文档也可以通过这个路径转换成飞书云文档。
410
410
 
411
+ ## Live Status 与最终回复
412
+
413
+ 非 verbose 模式下,执行过程显示一张可更新的 live status 卡片。运行中显示最近活动和耗时;完成后收缩为“累计工具调用次数 + 总耗时”。
414
+
415
+ 最终回复不会覆盖旧的 live status,而是在任务完成时新发一张卡片,因此用户执行途中插入的新消息不会排在最终答案之后。最终回复卡片底部只显示本次使用的模型名;工具调用次数和耗时只保留在 live status 中。
416
+
411
417
  ## 数据模型
412
418
 
413
419
  SQLite 状态位于配置的数据目录。主要表:
@@ -35,6 +35,11 @@ export declare class FeishuBot {
35
35
  private sendQueue;
36
36
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
37
37
  private delayedFailureTimers;
38
+ /** Ephemeral model footer overrides used by final-message wrappers. Keeping
39
+ * replyMessage/sendMessage two-argument APIs avoids contaminating ordinary
40
+ * command/error/card sends with model attribution. */
41
+ private replyModelFooters;
42
+ private sendModelFooters;
38
43
  /** Per-chat durable outbox retry timer. */
39
44
  private deliveryRetryTimers;
40
45
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
@@ -122,11 +127,8 @@ export declare class FeishuBot {
122
127
  private buildMarkdownCard;
123
128
  private deliveryOwnsVisiblePayload;
124
129
  private completionSummary;
125
- /** Final answer card used to replace the running live-status card in place. */
126
- private buildFinalAnswerCard;
127
- private patchFinalAnswerCard;
128
- /** Best-effort cleanup when a final-card patch was too large/failed and the
129
- * answer had to be delivered as a separate message. */
130
+ /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
131
+ * outbox entries that once targeted the live-status card. */
130
132
  private patchLiveStatusDoneSummary;
131
133
  private formatUserVisibleError;
132
134
  private isBridgeControlReply;
@@ -227,6 +229,7 @@ export declare class FeishuBot {
227
229
  private replyTextMessage;
228
230
  private editTextMessage;
229
231
  private deleteMessageById;
232
+ private replyFinalMessage;
230
233
  private replyMessage;
231
234
  private extractBridgeAttachments;
232
235
  /**
@@ -271,6 +274,7 @@ export declare class FeishuBot {
271
274
  /**
272
275
  * Send a proactive message to a chat (not a reply).
273
276
  */
277
+ private sendFinalMessage;
274
278
  private sendMessage;
275
279
  /**
276
280
  * Enqueue a message send to guarantee ordering per chat.
@@ -90,6 +90,11 @@ export class FeishuBot {
90
90
  sendQueue = new Map();
91
91
  /** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
92
92
  delayedFailureTimers = new Map();
93
+ /** Ephemeral model footer overrides used by final-message wrappers. Keeping
94
+ * replyMessage/sendMessage two-argument APIs avoids contaminating ordinary
95
+ * command/error/card sends with model attribution. */
96
+ replyModelFooters = new Map();
97
+ sendModelFooters = new Map();
93
98
  /** Per-chat durable outbox retry timer. */
94
99
  deliveryRetryTimers = new Map();
95
100
  /** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
@@ -233,18 +238,14 @@ export class FeishuBot {
233
238
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
234
239
  this.cancelDelayedFailure(chatId);
235
240
  const activeTarget = this.activeDeliveryTargets.get(chatId);
236
- let finalStatusMeta;
237
241
  try {
242
+ await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model);
238
243
  if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
239
- finalStatusMeta = await activeTarget?.liveStatus?.prepareTerminalDelivery(false);
240
- }
241
- await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, finalStatusMeta);
242
- if (!finalStatusMeta && sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0)) {
243
244
  await activeTarget?.liveStatus?.complete().catch(() => { });
244
245
  }
245
246
  }
246
247
  catch (err) {
247
- if (!finalStatusMeta && sourceType !== "verbose_transcript")
248
+ if (sourceType !== "verbose_transcript")
248
249
  await activeTarget?.liveStatus?.fail().catch(() => { });
249
250
  throw err;
250
251
  }
@@ -1435,21 +1436,12 @@ export class FeishuBot {
1435
1436
  await liveStatus?.complete().catch(() => { });
1436
1437
  }
1437
1438
  else {
1438
- let finalStatusMeta;
1439
1439
  try {
1440
- // If a live-status card exists, freeze it and persist enough
1441
- // metadata in the outbox to turn THIS SAME card into the final
1442
- // answer. Fast replies / disabled status / create failures have no
1443
- // card and transparently use the existing new-message path.
1444
- // Text answers become the card body. Attachment-only results still
1445
- // freeze the card so the outbox can durably collapse it to a done
1446
- // summary (including disabled-but-existing cards).
1447
- if ((shouldReply && visibleReply.trim()) || hasAttachments) {
1448
- finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
1449
- }
1450
- await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, finalStatusMeta);
1451
- if (!finalStatusMeta)
1452
- await liveStatus?.complete();
1440
+ // Keep live status and final answer as separate messages. The final
1441
+ // answer is created at completion time so it remains at the bottom
1442
+ // even when the user inserted more messages during the run.
1443
+ await this.enqueueAndDispatchDelivery(chatId, "assistant_visible", this.deliverySourceId("visible", `${triggerId}|${(shouldReply ? visibleReply : "").trim()}|${JSON.stringify(parsedReply.attachments)}`), shouldReply ? visibleReply : "", parsedReply.attachments, lastHuman.messageId, `trigger:${triggerId}`, this.config.model);
1444
+ await liveStatus?.complete();
1453
1445
  // Mark every merged trigger as delivered so retries/restarts do
1454
1446
  // not re-process any of them.
1455
1447
  for (const id of mergedTriggerIds)
@@ -1461,11 +1453,7 @@ export class FeishuBot {
1461
1453
  // enqueueAndDispatchDelivery already sent a user-visible delivery
1462
1454
  // failure. Do not fall through to the generic provider-error path;
1463
1455
  // that creates a second misleading "bot did not complete" message.
1464
- // If the card was not frozen for merged final delivery, retain the
1465
- // normal interrupted status. A prepared card has already been
1466
- // patched/fallen back by the durable dispatcher.
1467
- if (!finalStatusMeta)
1468
- await liveStatus?.fail().catch(() => { });
1456
+ await liveStatus?.fail().catch(() => { });
1469
1457
  console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
1470
1458
  }
1471
1459
  }
@@ -1714,13 +1702,19 @@ export class FeishuBot {
1714
1702
  }
1715
1703
  return s;
1716
1704
  }
1717
- buildMarkdownCard(text) {
1705
+ buildMarkdownCard(text, model) {
1706
+ const elements = buildFeishuCardElements(text);
1707
+ if (model?.trim()) {
1708
+ elements.push({ tag: "hr" });
1709
+ elements.push({
1710
+ tag: "markdown",
1711
+ content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
1712
+ });
1713
+ }
1718
1714
  return {
1719
1715
  schema: "2.0",
1720
1716
  config: { wide_screen_mode: true },
1721
- body: {
1722
- elements: buildFeishuCardElements(text),
1723
- },
1717
+ body: { elements },
1724
1718
  };
1725
1719
  }
1726
1720
  deliveryOwnsVisiblePayload(item) {
@@ -1738,33 +1732,12 @@ export class FeishuBot {
1738
1732
  }
1739
1733
  completionSummary(meta, emoji = "✅") {
1740
1734
  const en = meta.locale === "en";
1741
- const core = en
1735
+ return en
1742
1736
  ? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
1743
1737
  : `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
1744
- return meta.model ? `${core} · 🧠 ${this.escapeCardText(meta.model)}` : core;
1745
- }
1746
- /** Final answer card used to replace the running live-status card in place. */
1747
- buildFinalAnswerCard(text, meta) {
1748
- const elements = buildFeishuCardElements(text);
1749
- elements.push({ tag: "hr" });
1750
- elements.push({
1751
- tag: "markdown",
1752
- content: `<font color='grey'>${this.completionSummary(meta)}</font>`,
1753
- });
1754
- return {
1755
- schema: "2.0",
1756
- config: { update_multi: true, width_mode: "fill" },
1757
- body: { elements },
1758
- };
1759
- }
1760
- async patchFinalAnswerCard(messageId, text, meta, _chatId) {
1761
- await this.client.im.message.patch({
1762
- path: { message_id: messageId },
1763
- data: { content: JSON.stringify(this.buildFinalAnswerCard(text, meta)) },
1764
- });
1765
1738
  }
1766
- /** Best-effort cleanup when a final-card patch was too large/failed and the
1767
- * answer had to be delivered as a separate message. */
1739
+ /** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
1740
+ * outbox entries that once targeted the live-status card. */
1768
1741
  async patchLiveStatusDoneSummary(messageId, meta, chatId) {
1769
1742
  const view = {
1770
1743
  title: meta.locale === "en" ? `✅ ${this.config.name} done` : `✅ ${this.config.name} 已完成`,
@@ -2094,58 +2067,27 @@ export class FeishuBot {
2094
2067
  const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
2095
2068
  await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
2096
2069
  }
2097
- async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, liveStatusFinal) {
2070
+ async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
2098
2071
  if (!text.trim() && attachments.length === 0)
2099
2072
  return;
2100
2073
  const attachmentsJson = JSON.stringify(attachments);
2101
2074
  const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
2102
2075
  const contentHash = this.stableHash(normalizedPayload);
2103
2076
  const finalDeliveryKey = deliveryKey || sourceId;
2104
- const enqueueDurableStatusCleanup = async (meta, suffix) => {
2105
- const cleanupId = this.store.enqueueDelivery({
2106
- sessionKey: this.deliverySessionKey(chatId),
2107
- chatId,
2108
- botName: this.config.name,
2109
- sourceType: `${sourceType}_status_cleanup`,
2110
- sourceId: `${sourceId}:status-cleanup:${suffix}:${meta.messageId}`,
2111
- deliveryKey: `${finalDeliveryKey}:status-cleanup:${suffix}:${meta.messageId}`,
2112
- contentHash: "",
2113
- content: "",
2114
- attachmentsJson: "[]",
2115
- replyToMessageId: replyToMessageId || "",
2116
- deliveryMode: "patch_live_status",
2117
- targetMessageId: meta.messageId,
2118
- deliveryMetaJson: JSON.stringify(meta),
2119
- textDelivered: true,
2120
- cleanupPending: true,
2121
- });
2122
- if (cleanupId)
2123
- await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2124
- };
2125
- const finishDedupedStatus = async () => {
2126
- if (!liveStatusFinal)
2127
- return;
2128
- await enqueueDurableStatusCleanup(liveStatusFinal, "content-dedupe");
2129
- };
2077
+ const deliveryMetaJson = finalReplyModel?.trim()
2078
+ ? JSON.stringify({ model: finalReplyModel.trim() })
2079
+ : "{}";
2130
2080
  if (sourceType === "verbose_transcript") {
2131
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"])) {
2132
- await finishDedupedStatus();
2081
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
2133
2082
  return;
2134
- }
2135
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"])) {
2136
- await finishDedupedStatus();
2083
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
2137
2084
  return;
2138
- }
2139
2085
  }
2140
2086
  else if (!deliveryKey) {
2141
- if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType])) {
2142
- await finishDedupedStatus();
2087
+ if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
2143
2088
  return;
2144
- }
2145
- if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType])) {
2146
- await finishDedupedStatus();
2089
+ if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
2147
2090
  return;
2148
- }
2149
2091
  }
2150
2092
  const deliveryId = this.store.enqueueDelivery({
2151
2093
  sessionKey: this.deliverySessionKey(chatId),
@@ -2158,9 +2100,9 @@ export class FeishuBot {
2158
2100
  content: text,
2159
2101
  attachmentsJson,
2160
2102
  replyToMessageId: replyToMessageId || "",
2161
- deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2162
- targetMessageId: liveStatusFinal?.messageId || "",
2163
- deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2103
+ deliveryMode: "send",
2104
+ targetMessageId: "",
2105
+ deliveryMetaJson,
2164
2106
  });
2165
2107
  if (deliveryId === null) {
2166
2108
  // Another path already owns this stable logical delivery (commonly the
@@ -2182,9 +2124,9 @@ export class FeishuBot {
2182
2124
  sourceType: `${sourceType}_recovery`, sourceId: `${sourceId}:recovery`,
2183
2125
  deliveryKey: recoveryKey, contentHash, content: text, attachmentsJson: "[]",
2184
2126
  replyToMessageId: replyToMessageId || "",
2185
- deliveryMode: liveStatusFinal ? "patch_live_status" : "send",
2186
- targetMessageId: liveStatusFinal?.messageId || "",
2187
- deliveryMetaJson: liveStatusFinal ? JSON.stringify(liveStatusFinal) : "{}",
2127
+ deliveryMode: "send",
2128
+ targetMessageId: "",
2129
+ deliveryMetaJson,
2188
2130
  });
2189
2131
  }
2190
2132
  }
@@ -2199,11 +2141,9 @@ export class FeishuBot {
2199
2141
  sourceType: "assistant_visible_authoritative", sourceId: `${sourceId}:authoritative`,
2200
2142
  deliveryKey: correctionKey, contentHash, content: text, attachmentsJson: "[]",
2201
2143
  replyToMessageId: replyToMessageId || "",
2202
- deliveryMode: liveStatusFinal || existing.targetMessageId ? "patch_live_status" : "send",
2203
- targetMessageId: liveStatusFinal?.messageId || existing.targetMessageId,
2204
- deliveryMetaJson: liveStatusFinal?.messageId
2205
- ? JSON.stringify(liveStatusFinal)
2206
- : existing.deliveryMetaJson,
2144
+ deliveryMode: "send",
2145
+ targetMessageId: "",
2146
+ deliveryMetaJson,
2207
2147
  });
2208
2148
  }
2209
2149
  // A competing final may add attachments that the first owner did not see.
@@ -2265,34 +2205,6 @@ export class FeishuBot {
2265
2205
  }
2266
2206
  // Dispatch recovery/correction/supplement rows together in creation order.
2267
2207
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2268
- // A correction/recovery row can intentionally patch the newly-frozen card.
2269
- // Never enqueue a compact cleanup for that same card afterwards: it would
2270
- // overwrite the authoritative answer that was just patched into place.
2271
- const competingRows = this.store.listDeliveriesByKeyPrefix(this.config.name, chatId, `${finalDeliveryKey}:`);
2272
- const finalCardOwnsVisiblePayload = Boolean(liveStatusFinal) && [existing, ...competingRows]
2273
- .some((row) => row?.targetMessageId === liveStatusFinal.messageId && this.deliveryOwnsVisiblePayload(row));
2274
- if (liveStatusFinal && existing?.targetMessageId !== liveStatusFinal.messageId && !finalCardOwnsVisiblePayload) {
2275
- const cleanupJson = JSON.stringify(liveStatusFinal);
2276
- const cleanupId = this.store.enqueueDelivery({
2277
- sessionKey: this.deliverySessionKey(chatId),
2278
- chatId,
2279
- botName: this.config.name,
2280
- sourceType: `${sourceType}_status_cleanup`,
2281
- sourceId: `${sourceId}:status-cleanup:${liveStatusFinal.messageId}`,
2282
- deliveryKey: `${finalDeliveryKey}:status-cleanup:${liveStatusFinal.messageId}`,
2283
- contentHash: "",
2284
- content: "",
2285
- attachmentsJson: "[]",
2286
- replyToMessageId: replyToMessageId || "",
2287
- deliveryMode: "patch_live_status",
2288
- targetMessageId: liveStatusFinal.messageId,
2289
- deliveryMetaJson: cleanupJson,
2290
- textDelivered: true,
2291
- cleanupPending: true,
2292
- });
2293
- if (cleanupId)
2294
- await this.dispatchPendingDeliveries(chatId, replyToMessageId);
2295
- }
2296
2208
  return;
2297
2209
  }
2298
2210
  await this.dispatchPendingDeliveries(chatId, replyToMessageId);
@@ -2326,49 +2238,39 @@ export class FeishuBot {
2326
2238
  let failureStage = "text";
2327
2239
  try {
2328
2240
  const attachments = JSON.parse(item.attachmentsJson || "[]");
2329
- // Text first. A patch error is ambiguous: Feishu may have applied the
2330
- // patch but timed out returning the response. Do NOT immediately create
2331
- // a second visible answer. Retry the same idempotent patch; only after
2332
- // the retry budget is exhausted do we fall back to a new message.
2241
+ // Final text is always sent as a NEW message. Never patch it into the
2242
+ // older live-status card: user messages inserted during the run must
2243
+ // stay above the eventual final answer, not below it.
2333
2244
  if (item.content.trim() && !item.textDelivered) {
2334
2245
  const replyTarget = item.replyToMessageId || replyToMessageId;
2335
- const shouldReplyToSource = replyTarget && (item.sourceType === "assistant_visible" || item.sourceType === "verbose_transcript" || item.sourceType === "provider_error" || item.sourceType === "delayed_error");
2336
- if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2246
+ const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
2247
+ || item.sourceType === "verbose_transcript"
2248
+ || item.sourceType === "provider_error"
2249
+ || item.sourceType === "delayed_error");
2250
+ let finalModel;
2251
+ try {
2252
+ const meta = JSON.parse(item.deliveryMetaJson || "{}");
2253
+ finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
2254
+ }
2255
+ catch { /* legacy/malformed metadata: send without model footer */ }
2256
+ if (shouldReplyToSource) {
2337
2257
  try {
2338
- const meta = JSON.parse(item.deliveryMetaJson || "{}");
2339
- await this.patchFinalAnswerCard(item.targetMessageId, item.content, meta, chatId);
2340
- this.store.markDeliveryTextDelivered(item.id);
2258
+ await this.replyFinalMessage(replyTarget, item.content, finalModel);
2341
2259
  }
2342
- catch (patchErr) {
2343
- if (item.attempts + 1 < DELIVERY_MAX_ATTEMPTS)
2344
- throw patchErr;
2345
- console.warn(`[${this.config.name}] final-card patch exhausted; falling back to new message:`, this.errorSummary(patchErr));
2346
- if (shouldReplyToSource) {
2347
- try {
2348
- await this.replyMessage(replyTarget, item.content);
2349
- }
2350
- catch {
2351
- await this.sendMessage(chatId, item.content);
2352
- }
2353
- }
2354
- else {
2355
- await this.sendMessage(chatId, item.content);
2356
- }
2357
- this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2260
+ catch {
2261
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2358
2262
  }
2359
2263
  }
2360
2264
  else {
2361
- if (shouldReplyToSource) {
2362
- try {
2363
- await this.replyMessage(replyTarget, item.content);
2364
- }
2365
- catch {
2366
- await this.sendMessage(chatId, item.content);
2367
- }
2368
- }
2369
- else {
2370
- await this.sendMessage(chatId, item.content);
2371
- }
2265
+ await this.sendFinalMessage(chatId, item.content, finalModel);
2266
+ }
2267
+ // Rows created by v1.4.3 may still target a live-status card. Preserve
2268
+ // restart compatibility: send their text as a new message, then
2269
+ // collapse the old status card in the cleanup stage below.
2270
+ if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
2271
+ this.store.markDeliveryTextDeliveredWithCleanup(item.id);
2272
+ }
2273
+ else {
2372
2274
  this.store.markDeliveryTextDelivered(item.id);
2373
2275
  }
2374
2276
  }
@@ -2589,7 +2491,6 @@ export class FeishuBot {
2589
2491
  const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
2590
2492
  displayReply = `${displayReply}\n\n${roundMarker}`;
2591
2493
  }
2592
- let finalStatusMeta;
2593
2494
  if (isVisible || parsedReply.attachments.length > 0) {
2594
2495
  const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
2595
2496
  .filter(Boolean)
@@ -2603,14 +2504,10 @@ export class FeishuBot {
2603
2504
  timestamp: Date.now(),
2604
2505
  });
2605
2506
  try {
2606
- if (isVisible || parsedReply.attachments.length > 0) {
2607
- finalStatusMeta = await liveStatus?.prepareTerminalDelivery(false);
2608
- }
2609
- await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, finalStatusMeta);
2507
+ await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
2610
2508
  }
2611
2509
  catch (err) {
2612
- if (!finalStatusMeta)
2613
- await liveStatus?.fail().catch(() => { });
2510
+ await liveStatus?.fail().catch(() => { });
2614
2511
  throw err;
2615
2512
  }
2616
2513
  }
@@ -2619,7 +2516,7 @@ export class FeishuBot {
2619
2516
  if (!isVisible && parsedReply.attachments.length === 0) {
2620
2517
  await liveStatus?.noReply().catch(() => { });
2621
2518
  }
2622
- else if (!finalStatusMeta) {
2519
+ else {
2623
2520
  await liveStatus?.complete().catch(() => { });
2624
2521
  }
2625
2522
  return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
@@ -2634,7 +2531,6 @@ export class FeishuBot {
2634
2531
  const summary = this.completionSummary({
2635
2532
  toolCalls: view.toolCalls,
2636
2533
  elapsed: view.elapsed,
2637
- model: view.model,
2638
2534
  locale: en ? "en" : "zh",
2639
2535
  }, statusEmoji);
2640
2536
  return {
@@ -2667,12 +2563,10 @@ export class FeishuBot {
2667
2563
  elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
2668
2564
  }
2669
2565
  elements.push({ tag: "hr" });
2670
- // Footer: elapsed time + model name.
2671
- const footerBits = [];
2672
- footerBits.push(en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`);
2673
- if (view.model)
2674
- footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
2675
- elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
2566
+ // Running/failed status owns timing; model attribution belongs to the final
2567
+ // reply card so the two messages have clear, non-duplicated responsibilities.
2568
+ const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
2569
+ elements.push({ tag: "markdown", content: `<font color='grey'>${footer}</font>` });
2676
2570
  const template = view.state === "failed" ? "orange" : "blue";
2677
2571
  return {
2678
2572
  schema: "2.0",
@@ -2833,9 +2727,21 @@ export class FeishuBot {
2833
2727
  async deleteMessageById(messageId) {
2834
2728
  await this.client.im.v1.message.delete({ path: { message_id: messageId } });
2835
2729
  }
2730
+ async replyFinalMessage(messageId, text, model) {
2731
+ if (!model?.trim())
2732
+ return this.replyMessage(messageId, text);
2733
+ this.replyModelFooters.set(messageId, model.trim());
2734
+ try {
2735
+ return await this.replyMessage(messageId, text);
2736
+ }
2737
+ finally {
2738
+ this.replyModelFooters.delete(messageId);
2739
+ }
2740
+ }
2836
2741
  async replyMessage(messageId, text) {
2837
2742
  // Use Feishu CardKit v2 markdown component for full Markdown rendering.
2838
- const card = this.buildMarkdownCard(text);
2743
+ const model = this.replyModelFooters.get(messageId);
2744
+ const card = this.buildMarkdownCard(text, model);
2839
2745
  try {
2840
2746
  const res = await this.client.im.message.reply({
2841
2747
  path: { message_id: messageId },
@@ -2847,11 +2753,12 @@ export class FeishuBot {
2847
2753
  return res?.data?.message_id || res?.message_id;
2848
2754
  }
2849
2755
  catch {
2850
- // Fallback to plain text if card fails
2756
+ // Fallback to plain text if card fails; retain model attribution.
2757
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
2851
2758
  const res = await this.client.im.message.reply({
2852
2759
  path: { message_id: messageId },
2853
2760
  data: {
2854
- content: JSON.stringify({ text }),
2761
+ content: JSON.stringify({ text: fallbackText }),
2855
2762
  msg_type: "text",
2856
2763
  },
2857
2764
  });
@@ -3179,8 +3086,20 @@ export class FeishuBot {
3179
3086
  /**
3180
3087
  * Send a proactive message to a chat (not a reply).
3181
3088
  */
3089
+ async sendFinalMessage(chatId, text, model) {
3090
+ if (!model?.trim())
3091
+ return this.sendMessage(chatId, text);
3092
+ this.sendModelFooters.set(chatId, model.trim());
3093
+ try {
3094
+ return await this.sendMessage(chatId, text);
3095
+ }
3096
+ finally {
3097
+ this.sendModelFooters.delete(chatId);
3098
+ }
3099
+ }
3182
3100
  async sendMessage(chatId, text) {
3183
- const card = this.buildMarkdownCard(text);
3101
+ const model = this.sendModelFooters.get(chatId);
3102
+ const card = this.buildMarkdownCard(text, model);
3184
3103
  try {
3185
3104
  const res = await this.client.im.message.create({
3186
3105
  params: { receive_id_type: "chat_id" },
@@ -3197,13 +3116,14 @@ export class FeishuBot {
3197
3116
  console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
3198
3117
  if (this.isOutOfChatError(err))
3199
3118
  this.markCurrentBotUnavailable(chatId, err);
3200
- // Fallback to plain text
3119
+ // Fallback to plain text; retain model attribution.
3201
3120
  try {
3121
+ const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
3202
3122
  const res = await this.client.im.message.create({
3203
3123
  params: { receive_id_type: "chat_id" },
3204
3124
  data: {
3205
3125
  receive_id: chatId,
3206
- content: JSON.stringify({ text }),
3126
+ content: JSON.stringify({ text: fallbackText }),
3207
3127
  msg_type: "text",
3208
3128
  },
3209
3129
  });
@@ -41,12 +41,13 @@ export interface DeliveryOutboxItem {
41
41
  content: string;
42
42
  attachmentsJson: string;
43
43
  replyToMessageId: string;
44
- /** Delivery mode. `patch_live_status` turns the existing live-status card
45
- * into the final answer instead of creating a second Feishu message. */
44
+ /** Delivery mode. New final replies use `send`; `patch_live_status` is kept
45
+ * for status-only cleanup and restart compatibility with legacy outbox rows. */
46
46
  deliveryMode?: "send" | "patch_live_status";
47
47
  /** Existing interactive-card message id to patch for patch_live_status. */
48
48
  targetMessageId?: string;
49
- /** Persisted JSON footer metadata (tool calls / elapsed / model / locale). */
49
+ /** Persisted JSON delivery metadata (for example final-reply model footer,
50
+ * or legacy live-status cleanup metadata). */
50
51
  deliveryMetaJson?: string;
51
52
  /** Durable stage checkpoint: final text/card is already visible. */
52
53
  textDelivered?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-lark-multi-agent",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
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": {