openclaw-lark-multi-agent 1.4.3 → 1.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -19
- package/README.zh-CN.md +14 -0
- package/dist/cli.js +4 -4
- package/dist/compact-progress.d.ts +4 -4
- package/dist/compact-progress.js +5 -5
- package/dist/feishu-bot.d.ts +28 -14
- package/dist/feishu-bot.js +322 -282
- package/dist/message-store.d.ts +4 -3
- package/dist/openclaw-client.d.ts +32 -25
- package/dist/openclaw-client.js +327 -158
- package/package.json +2 -2
- package/plugins/lma-steer/README.md +23 -30
- package/dist/session-file-compactor.d.ts +0 -39
- package/dist/session-file-compactor.js +0 -176
package/dist/feishu-bot.js
CHANGED
|
@@ -9,7 +9,6 @@ import { basename, extname, join, resolve } from "path";
|
|
|
9
9
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
10
10
|
import { buildFeishuCardElements } from "./markdown.js";
|
|
11
11
|
import { discussionManager } from "./discussion-manager.js";
|
|
12
|
-
import { resolveSessionFilePath, toolTrimCompactFile } from "./session-file-compactor.js";
|
|
13
12
|
const require = createRequire(import.meta.url);
|
|
14
13
|
const LMA_VERSION = require("../package.json").version;
|
|
15
14
|
const MAX_BOT_STREAK = 10;
|
|
@@ -42,9 +41,13 @@ function steerInjectionPrefix(en) {
|
|
|
42
41
|
|| "[New message inserted by the user mid-task; please handle it together with the current task]";
|
|
43
42
|
return en ? enText : zh;
|
|
44
43
|
}
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
44
|
+
// Emergency transcript trim used only when semantic compaction cannot run.
|
|
45
|
+
// The Gateway owns the transcript store (JSONL before 2026.8, SQLite after it),
|
|
46
|
+
// so LMA must use sessions.compact instead of mutating storage directly.
|
|
47
|
+
function compactFallbackMaxLines() {
|
|
48
|
+
const value = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_COMPACT_FALLBACK_MAX_LINES || 200);
|
|
49
|
+
return Number.isFinite(value) ? Math.max(20, Math.floor(value)) : 200;
|
|
50
|
+
}
|
|
48
51
|
// After a reply is delivered, if context usage reaches this percent, send the
|
|
49
52
|
// user a one-time alert to compact. 0 disables the alert.
|
|
50
53
|
function contextAlertPct() { return Number(process.env.OPENCLAW_LARK_MULTI_AGENT_CONTEXT_ALERT_PCT || 80); }
|
|
@@ -90,14 +93,26 @@ export class FeishuBot {
|
|
|
90
93
|
sendQueue = new Map();
|
|
91
94
|
/** Per-chat delayed runtime failure notifications, canceled if a real reply arrives. */
|
|
92
95
|
delayedFailureTimers = new Map();
|
|
96
|
+
/** Ephemeral model footer overrides used by final-message wrappers. Keeping
|
|
97
|
+
* replyMessage/sendMessage two-argument APIs avoids contaminating ordinary
|
|
98
|
+
* command/error/card sends with model attribution. */
|
|
99
|
+
replyModelFooters = new Map();
|
|
100
|
+
sendModelFooters = new Map();
|
|
93
101
|
/** Per-chat durable outbox retry timer. */
|
|
94
102
|
deliveryRetryTimers = new Map();
|
|
95
103
|
/** Last time a real assistant-visible reply was successfully handed to the delivery pipeline. */
|
|
96
104
|
lastRealDeliveryAt = new Map();
|
|
97
105
|
/** Per-chat: whether we've already sent the high-context alert this cycle. */
|
|
98
106
|
contextAlerted = new Map();
|
|
99
|
-
/** Active
|
|
107
|
+
/** Active pre-admission target for the current chat operation. */
|
|
100
108
|
activeDeliveryTargets = new Map();
|
|
109
|
+
/** Stable targets for concurrent runs in one chat. A steer admission can
|
|
110
|
+
* become an ordinary run while another run remains active. */
|
|
111
|
+
deliveryTargetsByRun = new Map();
|
|
112
|
+
/** Provisional native-steer correlations keyed by durable message row. If the
|
|
113
|
+
* message falls back to a normal run, cancel these before submission so a
|
|
114
|
+
* later identical steer cannot consume the stale observer/correlation. */
|
|
115
|
+
pendingSteerCleanups = new Map();
|
|
101
116
|
adminOpenId;
|
|
102
117
|
locale;
|
|
103
118
|
configPath;
|
|
@@ -220,7 +235,10 @@ export class FeishuBot {
|
|
|
220
235
|
}
|
|
221
236
|
}
|
|
222
237
|
catch (err) {
|
|
238
|
+
// Session/model setup is authoritative. Propagate policy/capability errors
|
|
239
|
+
// so the queue can notify the user and never continue on the wrong model.
|
|
223
240
|
console.warn(`[${this.config.name}] ensureSession error:`, err.message);
|
|
241
|
+
throw err;
|
|
224
242
|
}
|
|
225
243
|
this.initializedSessions.add(sessionKey);
|
|
226
244
|
// Subscribe to session events (proactive messages + tool calls)
|
|
@@ -232,19 +250,21 @@ export class FeishuBot {
|
|
|
232
250
|
const parsed = this.extractBridgeAttachments(text);
|
|
233
251
|
if (sourceType !== "verbose_transcript" && (parsed.text.trim() || parsed.attachments.length > 0))
|
|
234
252
|
this.cancelDelayedFailure(chatId);
|
|
235
|
-
const
|
|
236
|
-
|
|
253
|
+
const runTarget = meta?.runId ? this.deliveryTargetsByRun.get(this.deliveryRunTargetKey(chatId, meta.runId)) : undefined;
|
|
254
|
+
const candidateTarget = this.activeDeliveryTargets.get(chatId);
|
|
255
|
+
const activeTarget = runTarget || (candidateTarget && (!candidateTarget.runId || !meta?.runId || candidateTarget.runId === meta.runId)
|
|
256
|
+
? candidateTarget
|
|
257
|
+
: undefined);
|
|
237
258
|
try {
|
|
259
|
+
await this.enqueueAndDispatchDelivery(chatId, sourceType, this.deliverySourceId(sourceType === "verbose_transcript" ? "verbose" : "proactive", `${Date.now()}:${Math.random()}:${parsed.text.trim()}|${JSON.stringify(parsed.attachments)}`), parsed.text.trim(), parsed.attachments, activeTarget?.messageId, activeTarget ? `trigger:${activeTarget.triggerId}` : undefined, sourceType === "verbose_transcript" ? undefined : this.config.model);
|
|
238
260
|
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
261
|
await activeTarget?.liveStatus?.complete().catch(() => { });
|
|
262
|
+
if (meta?.runId && runTarget)
|
|
263
|
+
this.releaseRunDeliveryTargetAfter(chatId, meta.runId, 30_000);
|
|
244
264
|
}
|
|
245
265
|
}
|
|
246
266
|
catch (err) {
|
|
247
|
-
if (
|
|
267
|
+
if (sourceType !== "verbose_transcript")
|
|
248
268
|
await activeTarget?.liveStatus?.fail().catch(() => { });
|
|
249
269
|
throw err;
|
|
250
270
|
}
|
|
@@ -922,44 +942,81 @@ export class FeishuBot {
|
|
|
922
942
|
// Wrap the message so the agent knows it is a NEW user message inserted
|
|
923
943
|
// mid-task. The live-status line and reactions still key off the original.
|
|
924
944
|
const steerWrapped = `${steerInjectionPrefix(this.isEn(chatId))}\n${cleanText}`;
|
|
945
|
+
// Register the consumption observer before submitting so a fast
|
|
946
|
+
// session.message cannot race past the Feishu reaction transition.
|
|
947
|
+
await this.addReaction(messageId, "Typing").catch(() => { });
|
|
948
|
+
pending.push({ messageId, emoji: "Typing", rowId: insertedId });
|
|
949
|
+
this.pendingAckMessages.set(chatId, pending);
|
|
950
|
+
const norm = cleanText.replace(/\s+/g, " ").trim();
|
|
951
|
+
const stopWatchingConsumption = this.openclawClient.onSteerConsumed(sessionKeyForSteer, steerWrapped, (consumedText) => {
|
|
952
|
+
const cn = (consumedText || "").replace(/\s+/g, " ").trim();
|
|
953
|
+
if (cn !== norm && !cn.includes(norm))
|
|
954
|
+
return false;
|
|
955
|
+
// Only transcript commitment/consumption transfers durable custody.
|
|
956
|
+
// Until this point the local pending trigger remains the fallback for
|
|
957
|
+
// detached dispatch rejection or an admission that never consumed.
|
|
958
|
+
if (insertedId > 0) {
|
|
959
|
+
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
960
|
+
this.pendingSteerCleanups.delete(insertedId);
|
|
961
|
+
}
|
|
962
|
+
void this.removeReaction(messageId, "Typing").catch(() => { });
|
|
963
|
+
void this.addReaction(messageId, "Get").catch(() => { });
|
|
964
|
+
const acks = this.pendingAckMessages.get(chatId) || [];
|
|
965
|
+
for (const a of acks)
|
|
966
|
+
if (a.messageId === messageId)
|
|
967
|
+
a.emoji = "Get";
|
|
968
|
+
this.pendingAckMessages.set(chatId, acks);
|
|
969
|
+
return true;
|
|
970
|
+
});
|
|
971
|
+
const previousActiveTarget = this.activeDeliveryTargets.get(chatId);
|
|
972
|
+
// Install the new Feishu delivery target before admission. OpenClaw may
|
|
973
|
+
// race from "old run still active" to "dispatch a new run" while
|
|
974
|
+
// handling queueMode=steer, and that new run can emit events before the
|
|
975
|
+
// RPC response reaches LMA. Either outcome now routes to this message.
|
|
976
|
+
const releaseSteerDeliveryTarget = this.setActiveDeliveryTarget(chatId, insertedId, messageId);
|
|
977
|
+
const provisionalTarget = this.activeDeliveryTargets.get(chatId);
|
|
925
978
|
const steer = await this.openclawClient.steer(sessionKeyForSteer, steerWrapped, cleanText).catch(() => ({ status: "unavailable" }));
|
|
926
979
|
if (steer.status === "steered") {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
this.pendingAckMessages.set(chatId, acks);
|
|
948
|
-
return true; // one-shot: matched, remove this callback
|
|
949
|
-
});
|
|
950
|
-
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, STEERED (awaiting consumption): "${cleanText.substring(0, 50)}..."`);
|
|
951
|
-
// The message is already in DB with a pending trigger. Clear that
|
|
952
|
-
// trigger so it is NOT re-run as a separate follow-up batch after the
|
|
953
|
-
// current run ends — it has already been injected into this run.
|
|
954
|
-
if (insertedId > 0)
|
|
955
|
-
this.store.clearPendingTrigger(this.config.name, chatId, insertedId);
|
|
980
|
+
if (insertedId > 0) {
|
|
981
|
+
this.pendingSteerCleanups.set(insertedId, () => {
|
|
982
|
+
stopWatchingConsumption();
|
|
983
|
+
if ("cancelPending" in steer)
|
|
984
|
+
steer.cancelPending?.();
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
if (provisionalTarget && steer.runId) {
|
|
988
|
+
provisionalTarget.runId = steer.runId;
|
|
989
|
+
this.bindRunDeliveryTarget(chatId, steer.runId, provisionalTarget);
|
|
990
|
+
this.releaseRunDeliveryTargetAfter(chatId, steer.runId, 30 * 60_000);
|
|
991
|
+
}
|
|
992
|
+
// A steer admission may become an ordinary new run. Keep its run-bound
|
|
993
|
+
// reply target long enough for a real tool-heavy run; stale targets are
|
|
994
|
+
// harmless because later runIds no longer match.
|
|
995
|
+
releaseSteerDeliveryTarget(30 * 60_000);
|
|
996
|
+
// RPC acknowledgment is provisional. Keep the durable pending trigger
|
|
997
|
+
// until session.message confirms consumption; otherwise the normal
|
|
998
|
+
// queue drains it after the current run.
|
|
999
|
+
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)}, steer accepted (awaiting transcript consumption): "${cleanText.substring(0, 50)}..."`);
|
|
956
1000
|
return;
|
|
957
1001
|
}
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1002
|
+
// Native steer was not accepted. Restore the previous run's routing
|
|
1003
|
+
// target and keep the already-created trigger/reaction for normal drain.
|
|
1004
|
+
stopWatchingConsumption();
|
|
1005
|
+
if ("cancelPending" in steer)
|
|
1006
|
+
steer.cancelPending?.();
|
|
1007
|
+
const currentTarget = this.activeDeliveryTargets.get(chatId);
|
|
1008
|
+
if (currentTarget && currentTarget.token === provisionalTarget?.token) {
|
|
1009
|
+
if (currentTarget.timer)
|
|
1010
|
+
clearTimeout(currentTarget.timer);
|
|
1011
|
+
this.activeDeliveryTargets.delete(chatId);
|
|
1012
|
+
if (previousActiveTarget) {
|
|
1013
|
+
const releaseRestoredTarget = this.setActiveDeliveryTarget(chatId, previousActiveTarget.triggerId, previousActiveTarget.messageId);
|
|
1014
|
+
const restored = this.activeDeliveryTargets.get(chatId);
|
|
1015
|
+
if (restored)
|
|
1016
|
+
restored.liveStatus = previousActiveTarget.liveStatus;
|
|
1017
|
+
releaseRestoredTarget();
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
963
1020
|
console.log(`[${this.config.name}] Agent busy for ${chatId.slice(-8)} (steer=${steer.status}), queuing: "${cleanText.substring(0, 50)}..."`);
|
|
964
1021
|
return; // Message is in DB, will be picked up when agent finishes
|
|
965
1022
|
}
|
|
@@ -1189,6 +1246,16 @@ export class FeishuBot {
|
|
|
1189
1246
|
const runStartStopEpoch = this.stopEpoch.get(chatId) || 0;
|
|
1190
1247
|
const mergedContent = mergedTriggers.map((m) => m.content).join("\n");
|
|
1191
1248
|
const mergedTriggerIds = mergedTriggers.map((m) => m.id || 0).filter(Boolean);
|
|
1249
|
+
// These rows are now taking the ordinary queue path. Remove any stale
|
|
1250
|
+
// provisional native-steer correlation before chat.send so a later
|
|
1251
|
+
// identical steer cannot acknowledge the wrong Feishu message.
|
|
1252
|
+
for (const id of mergedTriggerIds) {
|
|
1253
|
+
const cleanup = this.pendingSteerCleanups.get(id);
|
|
1254
|
+
if (cleanup) {
|
|
1255
|
+
cleanup();
|
|
1256
|
+
this.pendingSteerCleanups.delete(id);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1192
1259
|
const droppedTriggerIds = droppedMergedTriggers.map((m) => m.id || 0).filter(Boolean);
|
|
1193
1260
|
const mergedTriggerIdSet = new Set(mergedTriggerIds);
|
|
1194
1261
|
const completedTriggerIdSet = new Set([...droppedTriggerIds, ...mergedTriggerIds]);
|
|
@@ -1237,7 +1304,28 @@ export class FeishuBot {
|
|
|
1237
1304
|
}
|
|
1238
1305
|
const processedMessages = [...contextMsgs, ...mergedTriggers].filter((m) => m.id);
|
|
1239
1306
|
const queueStartedAt = Date.now();
|
|
1240
|
-
|
|
1307
|
+
let sessionKey;
|
|
1308
|
+
try {
|
|
1309
|
+
sessionKey = await this.ensureSession(chatId);
|
|
1310
|
+
}
|
|
1311
|
+
catch (err) {
|
|
1312
|
+
// Model policy/capability failures happen before chat.send. Clear the
|
|
1313
|
+
// busy marker, report the actionable error, and retire only this batch
|
|
1314
|
+
// so the chat cannot remain wedged indefinitely.
|
|
1315
|
+
this.busyChats.set(chatId, 0);
|
|
1316
|
+
const errorText = this.formatUserVisibleError(err);
|
|
1317
|
+
if (lastHuman.messageId) {
|
|
1318
|
+
await this.enqueueAndDispatchDelivery(chatId, "provider_error", `trigger:${triggerId}:session-setup-error`, errorText, [], lastHuman.messageId, `trigger:${triggerId}:session-setup-error`).catch(() => { });
|
|
1319
|
+
}
|
|
1320
|
+
for (const id of mergedTriggerIds)
|
|
1321
|
+
this.store.clearPendingTrigger(this.config.name, chatId, id);
|
|
1322
|
+
this.store.markMessagesSynced(this.config.name, chatId, mergedTriggerIds, `${this.config.name}:${chatId}:${triggerId}:session-setup-failed`);
|
|
1323
|
+
if (mergedTriggerIds.length > 0)
|
|
1324
|
+
this.store.markSynced(this.config.name, chatId, Math.max(...mergedTriggerIds));
|
|
1325
|
+
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
1326
|
+
this.pendingAckMessages.set(chatId, pendingAcks.filter((ack) => !completedTriggerIdSet.has(ack.rowId)));
|
|
1327
|
+
break;
|
|
1328
|
+
}
|
|
1241
1329
|
console.log(`[${this.config.name}] Sending ${mergedTriggers.length} trigger(s) as 1 run to OpenClaw for ${chatId.slice(-8)} (context=${contextMsgs.length}, dropped=${droppedTriggerIds.length})`);
|
|
1242
1330
|
// Update reactions: queued messages → sent (GET/了解)
|
|
1243
1331
|
const pendingAcks = this.pendingAckMessages.get(chatId) || [];
|
|
@@ -1303,6 +1391,12 @@ export class FeishuBot {
|
|
|
1303
1391
|
},
|
|
1304
1392
|
onSubmitted: (runId) => {
|
|
1305
1393
|
runAcceptedByOpenClaw = true;
|
|
1394
|
+
const target = this.activeDeliveryTargets.get(chatId);
|
|
1395
|
+
if (target?.triggerId === triggerId) {
|
|
1396
|
+
target.runId = runId;
|
|
1397
|
+
this.bindRunDeliveryTarget(chatId, runId, target);
|
|
1398
|
+
this.releaseRunDeliveryTargetAfter(chatId, runId, 30 * 60_000);
|
|
1399
|
+
}
|
|
1306
1400
|
// Refine state for logs/idempotent sync rows when OpenClaw returns
|
|
1307
1401
|
// a runId. markSubmittedBatch is idempotent due to local flags and
|
|
1308
1402
|
// INSERT OR IGNORE in message_sync.
|
|
@@ -1435,21 +1529,12 @@ export class FeishuBot {
|
|
|
1435
1529
|
await liveStatus?.complete().catch(() => { });
|
|
1436
1530
|
}
|
|
1437
1531
|
else {
|
|
1438
|
-
let finalStatusMeta;
|
|
1439
1532
|
try {
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
//
|
|
1443
|
-
|
|
1444
|
-
|
|
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();
|
|
1533
|
+
// Keep live status and final answer as separate messages. The final
|
|
1534
|
+
// answer is created at completion time so it remains at the bottom
|
|
1535
|
+
// even when the user inserted more messages during the run.
|
|
1536
|
+
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);
|
|
1537
|
+
await liveStatus?.complete();
|
|
1453
1538
|
// Mark every merged trigger as delivered so retries/restarts do
|
|
1454
1539
|
// not re-process any of them.
|
|
1455
1540
|
for (const id of mergedTriggerIds)
|
|
@@ -1461,11 +1546,7 @@ export class FeishuBot {
|
|
|
1461
1546
|
// enqueueAndDispatchDelivery already sent a user-visible delivery
|
|
1462
1547
|
// failure. Do not fall through to the generic provider-error path;
|
|
1463
1548
|
// that creates a second misleading "bot did not complete" message.
|
|
1464
|
-
|
|
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(() => { });
|
|
1549
|
+
await liveStatus?.fail().catch(() => { });
|
|
1469
1550
|
console.warn(`[${this.config.name}] assistant delivery failed after notification:`, this.errorSummary(err));
|
|
1470
1551
|
}
|
|
1471
1552
|
}
|
|
@@ -1714,13 +1795,19 @@ export class FeishuBot {
|
|
|
1714
1795
|
}
|
|
1715
1796
|
return s;
|
|
1716
1797
|
}
|
|
1717
|
-
buildMarkdownCard(text) {
|
|
1798
|
+
buildMarkdownCard(text, model) {
|
|
1799
|
+
const elements = buildFeishuCardElements(text);
|
|
1800
|
+
if (model?.trim()) {
|
|
1801
|
+
elements.push({ tag: "hr" });
|
|
1802
|
+
elements.push({
|
|
1803
|
+
tag: "markdown",
|
|
1804
|
+
content: `<font color='grey'>🧠 ${this.escapeCardText(model.trim())}</font>`,
|
|
1805
|
+
});
|
|
1806
|
+
}
|
|
1718
1807
|
return {
|
|
1719
1808
|
schema: "2.0",
|
|
1720
1809
|
config: { wide_screen_mode: true },
|
|
1721
|
-
body: {
|
|
1722
|
-
elements: buildFeishuCardElements(text),
|
|
1723
|
-
},
|
|
1810
|
+
body: { elements },
|
|
1724
1811
|
};
|
|
1725
1812
|
}
|
|
1726
1813
|
deliveryOwnsVisiblePayload(item) {
|
|
@@ -1738,33 +1825,12 @@ export class FeishuBot {
|
|
|
1738
1825
|
}
|
|
1739
1826
|
completionSummary(meta, emoji = "✅") {
|
|
1740
1827
|
const en = meta.locale === "en";
|
|
1741
|
-
|
|
1828
|
+
return en
|
|
1742
1829
|
? `${emoji} ${meta.toolCalls} tool call${meta.toolCalls === 1 ? "" : "s"} · ⏱ ${meta.elapsed}`
|
|
1743
1830
|
: `${emoji} 累计${meta.toolCalls} 次工具调用 · ⏱ 耗时${meta.elapsed}`;
|
|
1744
|
-
return meta.model ? `${core} · 🧠 ${this.escapeCardText(meta.model)}` : core;
|
|
1745
1831
|
}
|
|
1746
|
-
/**
|
|
1747
|
-
|
|
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
|
-
}
|
|
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. */
|
|
1832
|
+
/** Best-effort terminal cleanup for status-only rows and legacy v1.4.3
|
|
1833
|
+
* outbox entries that once targeted the live-status card. */
|
|
1768
1834
|
async patchLiveStatusDoneSummary(messageId, meta, chatId) {
|
|
1769
1835
|
const view = {
|
|
1770
1836
|
title: meta.locale === "en" ? `✅ ${this.config.name} done` : `✅ ${this.config.name} 已完成`,
|
|
@@ -2039,18 +2105,48 @@ export class FeishuBot {
|
|
|
2039
2105
|
clearTimeout(timer);
|
|
2040
2106
|
this.delayedFailureTimers.delete(chatId);
|
|
2041
2107
|
}
|
|
2108
|
+
deliveryRunTargetKey(chatId, runId) {
|
|
2109
|
+
return `${chatId}\u0000${runId}`;
|
|
2110
|
+
}
|
|
2111
|
+
bindRunDeliveryTarget(chatId, runId, target) {
|
|
2112
|
+
if (!runId)
|
|
2113
|
+
return;
|
|
2114
|
+
const key = this.deliveryRunTargetKey(chatId, runId);
|
|
2115
|
+
const previous = this.deliveryTargetsByRun.get(key);
|
|
2116
|
+
if (previous?.timer)
|
|
2117
|
+
clearTimeout(previous.timer);
|
|
2118
|
+
this.deliveryTargetsByRun.set(key, {
|
|
2119
|
+
triggerId: target.triggerId,
|
|
2120
|
+
messageId: target.messageId,
|
|
2121
|
+
liveStatus: target.liveStatus,
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
releaseRunDeliveryTargetAfter(chatId, runId, delayMs) {
|
|
2125
|
+
const key = this.deliveryRunTargetKey(chatId, runId);
|
|
2126
|
+
const target = this.deliveryTargetsByRun.get(key);
|
|
2127
|
+
if (!target)
|
|
2128
|
+
return;
|
|
2129
|
+
if (target.timer)
|
|
2130
|
+
clearTimeout(target.timer);
|
|
2131
|
+
const timer = setTimeout(() => {
|
|
2132
|
+
if (this.deliveryTargetsByRun.get(key)?.timer === timer)
|
|
2133
|
+
this.deliveryTargetsByRun.delete(key);
|
|
2134
|
+
}, delayMs);
|
|
2135
|
+
timer.unref?.();
|
|
2136
|
+
target.timer = timer;
|
|
2137
|
+
}
|
|
2042
2138
|
setActiveDeliveryTarget(chatId, triggerId, messageId) {
|
|
2043
2139
|
const existing = this.activeDeliveryTargets.get(chatId);
|
|
2044
2140
|
if (existing?.timer)
|
|
2045
2141
|
clearTimeout(existing.timer);
|
|
2046
2142
|
const token = Symbol(`${chatId}:${triggerId}`);
|
|
2047
2143
|
this.activeDeliveryTargets.set(chatId, { triggerId, messageId, token });
|
|
2048
|
-
return () => {
|
|
2144
|
+
return (delayMs = 60_000) => {
|
|
2049
2145
|
const timer = setTimeout(() => {
|
|
2050
2146
|
const current = this.activeDeliveryTargets.get(chatId);
|
|
2051
2147
|
if (current?.token === token)
|
|
2052
2148
|
this.activeDeliveryTargets.delete(chatId);
|
|
2053
|
-
},
|
|
2149
|
+
}, delayMs);
|
|
2054
2150
|
const current = this.activeDeliveryTargets.get(chatId);
|
|
2055
2151
|
if (current?.token === token)
|
|
2056
2152
|
current.timer = timer;
|
|
@@ -2094,58 +2190,27 @@ export class FeishuBot {
|
|
|
2094
2190
|
const sourceId = `discussion-system:${Date.now()}:${Math.random().toString(36).slice(2)}`;
|
|
2095
2191
|
await this.enqueueAndDispatchDelivery(chatId, "discussion_system", sourceId, text);
|
|
2096
2192
|
}
|
|
2097
|
-
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey,
|
|
2193
|
+
async enqueueAndDispatchDelivery(chatId, sourceType, sourceId, text, attachments = [], replyToMessageId, deliveryKey, finalReplyModel) {
|
|
2098
2194
|
if (!text.trim() && attachments.length === 0)
|
|
2099
2195
|
return;
|
|
2100
2196
|
const attachmentsJson = JSON.stringify(attachments);
|
|
2101
2197
|
const normalizedPayload = `${text.trim()}|${attachmentsJson}`;
|
|
2102
2198
|
const contentHash = this.stableHash(normalizedPayload);
|
|
2103
2199
|
const finalDeliveryKey = deliveryKey || sourceId;
|
|
2104
|
-
const
|
|
2105
|
-
|
|
2106
|
-
|
|
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
|
-
};
|
|
2200
|
+
const deliveryMetaJson = finalReplyModel?.trim()
|
|
2201
|
+
? JSON.stringify({ model: finalReplyModel.trim() })
|
|
2202
|
+
: "{}";
|
|
2130
2203
|
if (sourceType === "verbose_transcript") {
|
|
2131
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
|
|
2132
|
-
await finishDedupedStatus();
|
|
2204
|
+
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, ["verbose_transcript"]))
|
|
2133
2205
|
return;
|
|
2134
|
-
|
|
2135
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"])) {
|
|
2136
|
-
await finishDedupedStatus();
|
|
2206
|
+
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, ["verbose_transcript"]))
|
|
2137
2207
|
return;
|
|
2138
|
-
}
|
|
2139
2208
|
}
|
|
2140
2209
|
else if (!deliveryKey) {
|
|
2141
|
-
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
|
|
2142
|
-
await finishDedupedStatus();
|
|
2210
|
+
if (this.store.hasRecentSimilarDelivery(this.config.name, chatId, contentHash, 60_000, [sourceType]))
|
|
2143
2211
|
return;
|
|
2144
|
-
|
|
2145
|
-
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType])) {
|
|
2146
|
-
await finishDedupedStatus();
|
|
2212
|
+
if (this.store.hasRecentOverlappingDelivery(this.config.name, chatId, text, attachmentsJson, 60_000, 8, [sourceType]))
|
|
2147
2213
|
return;
|
|
2148
|
-
}
|
|
2149
2214
|
}
|
|
2150
2215
|
const deliveryId = this.store.enqueueDelivery({
|
|
2151
2216
|
sessionKey: this.deliverySessionKey(chatId),
|
|
@@ -2158,9 +2223,9 @@ export class FeishuBot {
|
|
|
2158
2223
|
content: text,
|
|
2159
2224
|
attachmentsJson,
|
|
2160
2225
|
replyToMessageId: replyToMessageId || "",
|
|
2161
|
-
deliveryMode:
|
|
2162
|
-
targetMessageId:
|
|
2163
|
-
deliveryMetaJson
|
|
2226
|
+
deliveryMode: "send",
|
|
2227
|
+
targetMessageId: "",
|
|
2228
|
+
deliveryMetaJson,
|
|
2164
2229
|
});
|
|
2165
2230
|
if (deliveryId === null) {
|
|
2166
2231
|
// Another path already owns this stable logical delivery (commonly the
|
|
@@ -2182,9 +2247,9 @@ export class FeishuBot {
|
|
|
2182
2247
|
sourceType: `${sourceType}_recovery`, sourceId: `${sourceId}:recovery`,
|
|
2183
2248
|
deliveryKey: recoveryKey, contentHash, content: text, attachmentsJson: "[]",
|
|
2184
2249
|
replyToMessageId: replyToMessageId || "",
|
|
2185
|
-
deliveryMode:
|
|
2186
|
-
targetMessageId:
|
|
2187
|
-
deliveryMetaJson
|
|
2250
|
+
deliveryMode: "send",
|
|
2251
|
+
targetMessageId: "",
|
|
2252
|
+
deliveryMetaJson,
|
|
2188
2253
|
});
|
|
2189
2254
|
}
|
|
2190
2255
|
}
|
|
@@ -2199,11 +2264,9 @@ export class FeishuBot {
|
|
|
2199
2264
|
sourceType: "assistant_visible_authoritative", sourceId: `${sourceId}:authoritative`,
|
|
2200
2265
|
deliveryKey: correctionKey, contentHash, content: text, attachmentsJson: "[]",
|
|
2201
2266
|
replyToMessageId: replyToMessageId || "",
|
|
2202
|
-
deliveryMode:
|
|
2203
|
-
targetMessageId:
|
|
2204
|
-
deliveryMetaJson
|
|
2205
|
-
? JSON.stringify(liveStatusFinal)
|
|
2206
|
-
: existing.deliveryMetaJson,
|
|
2267
|
+
deliveryMode: "send",
|
|
2268
|
+
targetMessageId: "",
|
|
2269
|
+
deliveryMetaJson,
|
|
2207
2270
|
});
|
|
2208
2271
|
}
|
|
2209
2272
|
// A competing final may add attachments that the first owner did not see.
|
|
@@ -2265,34 +2328,6 @@ export class FeishuBot {
|
|
|
2265
2328
|
}
|
|
2266
2329
|
// Dispatch recovery/correction/supplement rows together in creation order.
|
|
2267
2330
|
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
2331
|
return;
|
|
2297
2332
|
}
|
|
2298
2333
|
await this.dispatchPendingDeliveries(chatId, replyToMessageId);
|
|
@@ -2326,49 +2361,39 @@ export class FeishuBot {
|
|
|
2326
2361
|
let failureStage = "text";
|
|
2327
2362
|
try {
|
|
2328
2363
|
const attachments = JSON.parse(item.attachmentsJson || "[]");
|
|
2329
|
-
//
|
|
2330
|
-
//
|
|
2331
|
-
//
|
|
2332
|
-
// the retry budget is exhausted do we fall back to a new message.
|
|
2364
|
+
// Final text is always sent as a NEW message. Never patch it into the
|
|
2365
|
+
// older live-status card: user messages inserted during the run must
|
|
2366
|
+
// stay above the eventual final answer, not below it.
|
|
2333
2367
|
if (item.content.trim() && !item.textDelivered) {
|
|
2334
2368
|
const replyTarget = item.replyToMessageId || replyToMessageId;
|
|
2335
|
-
const shouldReplyToSource = replyTarget && (item.sourceType
|
|
2336
|
-
|
|
2369
|
+
const shouldReplyToSource = Boolean(replyTarget) && (item.sourceType.startsWith("assistant_visible")
|
|
2370
|
+
|| item.sourceType === "verbose_transcript"
|
|
2371
|
+
|| item.sourceType === "provider_error"
|
|
2372
|
+
|| item.sourceType === "delayed_error");
|
|
2373
|
+
let finalModel;
|
|
2374
|
+
try {
|
|
2375
|
+
const meta = JSON.parse(item.deliveryMetaJson || "{}");
|
|
2376
|
+
finalModel = typeof meta.model === "string" && meta.model.trim() ? meta.model.trim() : undefined;
|
|
2377
|
+
}
|
|
2378
|
+
catch { /* legacy/malformed metadata: send without model footer */ }
|
|
2379
|
+
if (shouldReplyToSource) {
|
|
2337
2380
|
try {
|
|
2338
|
-
|
|
2339
|
-
await this.patchFinalAnswerCard(item.targetMessageId, item.content, meta, chatId);
|
|
2340
|
-
this.store.markDeliveryTextDelivered(item.id);
|
|
2381
|
+
await this.replyFinalMessage(replyTarget, item.content, finalModel);
|
|
2341
2382
|
}
|
|
2342
|
-
catch
|
|
2343
|
-
|
|
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);
|
|
2383
|
+
catch {
|
|
2384
|
+
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2358
2385
|
}
|
|
2359
2386
|
}
|
|
2360
2387
|
else {
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
await this.sendMessage(chatId, item.content);
|
|
2371
|
-
}
|
|
2388
|
+
await this.sendFinalMessage(chatId, item.content, finalModel);
|
|
2389
|
+
}
|
|
2390
|
+
// Rows created by v1.4.3 may still target a live-status card. Preserve
|
|
2391
|
+
// restart compatibility: send their text as a new message, then
|
|
2392
|
+
// collapse the old status card in the cleanup stage below.
|
|
2393
|
+
if (item.deliveryMode === "patch_live_status" && item.targetMessageId) {
|
|
2394
|
+
this.store.markDeliveryTextDeliveredWithCleanup(item.id);
|
|
2395
|
+
}
|
|
2396
|
+
else {
|
|
2372
2397
|
this.store.markDeliveryTextDelivered(item.id);
|
|
2373
2398
|
}
|
|
2374
2399
|
}
|
|
@@ -2589,7 +2614,6 @@ export class FeishuBot {
|
|
|
2589
2614
|
const roundMarker = `—— 第 ${meta.round}/${meta.maxRounds} 轮 · ${this.config.name}`;
|
|
2590
2615
|
displayReply = `${displayReply}\n\n${roundMarker}`;
|
|
2591
2616
|
}
|
|
2592
|
-
let finalStatusMeta;
|
|
2593
2617
|
if (isVisible || parsedReply.attachments.length > 0) {
|
|
2594
2618
|
const storedContent = [displayReply, ...parsedReply.attachments.map((a) => `[Attachment: ${a.type || "file"} ${a.path}]`)]
|
|
2595
2619
|
.filter(Boolean)
|
|
@@ -2603,14 +2627,10 @@ export class FeishuBot {
|
|
|
2603
2627
|
timestamp: Date.now(),
|
|
2604
2628
|
});
|
|
2605
2629
|
try {
|
|
2606
|
-
|
|
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);
|
|
2630
|
+
await this.enqueueAndDispatchDelivery(chatId, "discussion", `discussion:${Date.now()}:${Math.random().toString(36).slice(2)}`, isVisible ? displayReply : "", parsedReply.attachments, undefined, undefined, this.config.model);
|
|
2610
2631
|
}
|
|
2611
2632
|
catch (err) {
|
|
2612
|
-
|
|
2613
|
-
await liveStatus?.fail().catch(() => { });
|
|
2633
|
+
await liveStatus?.fail().catch(() => { });
|
|
2614
2634
|
throw err;
|
|
2615
2635
|
}
|
|
2616
2636
|
}
|
|
@@ -2619,7 +2639,7 @@ export class FeishuBot {
|
|
|
2619
2639
|
if (!isVisible && parsedReply.attachments.length === 0) {
|
|
2620
2640
|
await liveStatus?.noReply().catch(() => { });
|
|
2621
2641
|
}
|
|
2622
|
-
else
|
|
2642
|
+
else {
|
|
2623
2643
|
await liveStatus?.complete().catch(() => { });
|
|
2624
2644
|
}
|
|
2625
2645
|
return { botName: this.config.name, text: cleanVisibleReply, visible: isVisible };
|
|
@@ -2634,7 +2654,6 @@ export class FeishuBot {
|
|
|
2634
2654
|
const summary = this.completionSummary({
|
|
2635
2655
|
toolCalls: view.toolCalls,
|
|
2636
2656
|
elapsed: view.elapsed,
|
|
2637
|
-
model: view.model,
|
|
2638
2657
|
locale: en ? "en" : "zh",
|
|
2639
2658
|
}, statusEmoji);
|
|
2640
2659
|
return {
|
|
@@ -2667,12 +2686,10 @@ export class FeishuBot {
|
|
|
2667
2686
|
elements.push({ tag: "markdown", content: en ? "_working…_" : "_正在启动…_" });
|
|
2668
2687
|
}
|
|
2669
2688
|
elements.push({ tag: "hr" });
|
|
2670
|
-
//
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
footerBits.push(`🧠 ${this.escapeCardText(view.model)}`);
|
|
2675
|
-
elements.push({ tag: "markdown", content: `<font color='grey'>${footerBits.join(" · ")}</font>` });
|
|
2689
|
+
// Running/failed status owns timing; model attribution belongs to the final
|
|
2690
|
+
// reply card so the two messages have clear, non-duplicated responsibilities.
|
|
2691
|
+
const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
|
|
2692
|
+
elements.push({ tag: "markdown", content: `<font color='grey'>${footer}</font>` });
|
|
2676
2693
|
const template = view.state === "failed" ? "orange" : "blue";
|
|
2677
2694
|
return {
|
|
2678
2695
|
schema: "2.0",
|
|
@@ -2756,8 +2773,8 @@ export class FeishuBot {
|
|
|
2756
2773
|
};
|
|
2757
2774
|
}
|
|
2758
2775
|
// Running: phase line + ticking elapsed footer.
|
|
2759
|
-
const phaseLine = view.phase === "
|
|
2760
|
-
? (en ? "🧹
|
|
2776
|
+
const phaseLine = view.phase === "transcript-trim"
|
|
2777
|
+
? (en ? "🧹 Semantic compaction unavailable — trimming transcript safely…" : "🧹 语义压缩不可用,正在安全裁剪转录…")
|
|
2761
2778
|
: (en ? "🧹 Compacting session…" : "🧹 正在压缩 session…");
|
|
2762
2779
|
const footer = en ? `⏱ ${view.elapsed}` : `⏱ 已用 ${view.elapsed}`;
|
|
2763
2780
|
return {
|
|
@@ -2833,9 +2850,21 @@ export class FeishuBot {
|
|
|
2833
2850
|
async deleteMessageById(messageId) {
|
|
2834
2851
|
await this.client.im.v1.message.delete({ path: { message_id: messageId } });
|
|
2835
2852
|
}
|
|
2853
|
+
async replyFinalMessage(messageId, text, model) {
|
|
2854
|
+
if (!model?.trim())
|
|
2855
|
+
return this.replyMessage(messageId, text);
|
|
2856
|
+
this.replyModelFooters.set(messageId, model.trim());
|
|
2857
|
+
try {
|
|
2858
|
+
return await this.replyMessage(messageId, text);
|
|
2859
|
+
}
|
|
2860
|
+
finally {
|
|
2861
|
+
this.replyModelFooters.delete(messageId);
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2836
2864
|
async replyMessage(messageId, text) {
|
|
2837
2865
|
// Use Feishu CardKit v2 markdown component for full Markdown rendering.
|
|
2838
|
-
const
|
|
2866
|
+
const model = this.replyModelFooters.get(messageId);
|
|
2867
|
+
const card = this.buildMarkdownCard(text, model);
|
|
2839
2868
|
try {
|
|
2840
2869
|
const res = await this.client.im.message.reply({
|
|
2841
2870
|
path: { message_id: messageId },
|
|
@@ -2847,11 +2876,12 @@ export class FeishuBot {
|
|
|
2847
2876
|
return res?.data?.message_id || res?.message_id;
|
|
2848
2877
|
}
|
|
2849
2878
|
catch {
|
|
2850
|
-
// Fallback to plain text if card fails
|
|
2879
|
+
// Fallback to plain text if card fails; retain model attribution.
|
|
2880
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
2851
2881
|
const res = await this.client.im.message.reply({
|
|
2852
2882
|
path: { message_id: messageId },
|
|
2853
2883
|
data: {
|
|
2854
|
-
content: JSON.stringify({ text }),
|
|
2884
|
+
content: JSON.stringify({ text: fallbackText }),
|
|
2855
2885
|
msg_type: "text",
|
|
2856
2886
|
},
|
|
2857
2887
|
});
|
|
@@ -3179,8 +3209,20 @@ export class FeishuBot {
|
|
|
3179
3209
|
/**
|
|
3180
3210
|
* Send a proactive message to a chat (not a reply).
|
|
3181
3211
|
*/
|
|
3212
|
+
async sendFinalMessage(chatId, text, model) {
|
|
3213
|
+
if (!model?.trim())
|
|
3214
|
+
return this.sendMessage(chatId, text);
|
|
3215
|
+
this.sendModelFooters.set(chatId, model.trim());
|
|
3216
|
+
try {
|
|
3217
|
+
return await this.sendMessage(chatId, text);
|
|
3218
|
+
}
|
|
3219
|
+
finally {
|
|
3220
|
+
this.sendModelFooters.delete(chatId);
|
|
3221
|
+
}
|
|
3222
|
+
}
|
|
3182
3223
|
async sendMessage(chatId, text) {
|
|
3183
|
-
const
|
|
3224
|
+
const model = this.sendModelFooters.get(chatId);
|
|
3225
|
+
const card = this.buildMarkdownCard(text, model);
|
|
3184
3226
|
try {
|
|
3185
3227
|
const res = await this.client.im.message.create({
|
|
3186
3228
|
params: { receive_id_type: "chat_id" },
|
|
@@ -3197,13 +3239,14 @@ export class FeishuBot {
|
|
|
3197
3239
|
console.warn(`[${this.config.name}] sendMessage interactive failed:`, JSON.stringify(err?.response?.data || err?.data || { message: err.message }));
|
|
3198
3240
|
if (this.isOutOfChatError(err))
|
|
3199
3241
|
this.markCurrentBotUnavailable(chatId, err);
|
|
3200
|
-
// Fallback to plain text
|
|
3242
|
+
// Fallback to plain text; retain model attribution.
|
|
3201
3243
|
try {
|
|
3244
|
+
const fallbackText = model?.trim() ? `${text}\n\n🧠 ${model.trim()}` : text;
|
|
3202
3245
|
const res = await this.client.im.message.create({
|
|
3203
3246
|
params: { receive_id_type: "chat_id" },
|
|
3204
3247
|
data: {
|
|
3205
3248
|
receive_id: chatId,
|
|
3206
|
-
content: JSON.stringify({ text }),
|
|
3249
|
+
content: JSON.stringify({ text: fallbackText }),
|
|
3207
3250
|
msg_type: "text",
|
|
3208
3251
|
},
|
|
3209
3252
|
});
|
|
@@ -3489,17 +3532,16 @@ export class FeishuBot {
|
|
|
3489
3532
|
* Handle /compact command: compress session context.
|
|
3490
3533
|
*/
|
|
3491
3534
|
/**
|
|
3492
|
-
* Compact a session with a fallback chain:
|
|
3493
|
-
* 1.
|
|
3494
|
-
* 2. If
|
|
3495
|
-
*
|
|
3496
|
-
*
|
|
3497
|
-
*
|
|
3498
|
-
*
|
|
3499
|
-
*
|
|
3535
|
+
* Compact a session with a Gateway-owned fallback chain:
|
|
3536
|
+
* 1. Semantic compaction through `sessions.compact`.
|
|
3537
|
+
* 2. If summarization cannot run, ask the same RPC to retain only a bounded
|
|
3538
|
+
* transcript tail via `maxLines`. This works with legacy JSONL and the
|
|
3539
|
+
* SQLite session store introduced in OpenClaw 2026.8.x, and preserves
|
|
3540
|
+
* OpenClaw's write locks, transcript invariants, and migration ownership.
|
|
3541
|
+
*
|
|
3542
|
+
* LMA deliberately never opens or rewrites OpenClaw transcript storage.
|
|
3500
3543
|
*/
|
|
3501
3544
|
async compactWithFallback(sessionKey, onPhase) {
|
|
3502
|
-
// 1. Native compaction first.
|
|
3503
3545
|
let nativeReason;
|
|
3504
3546
|
try {
|
|
3505
3547
|
const res = await this.openclawClient.compactSession(sessionKey);
|
|
@@ -3510,48 +3552,41 @@ export class FeishuBot {
|
|
|
3510
3552
|
catch (err) {
|
|
3511
3553
|
nativeReason = err.message;
|
|
3512
3554
|
}
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
// slow→still-working transition the user most needs to see).
|
|
3516
|
-
onPhase?.("tool-trim");
|
|
3517
|
-
let sessionId;
|
|
3555
|
+
onPhase?.("transcript-trim");
|
|
3556
|
+
const maxLines = compactFallbackMaxLines();
|
|
3518
3557
|
try {
|
|
3519
|
-
const
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
return { compacted: false, method: "none", reason: trim.reason || "tool-trim failed" };
|
|
3558
|
+
const trimmed = await this.openclawClient.compactSession(sessionKey, { maxLines });
|
|
3559
|
+
if (trimmed?.compacted === true) {
|
|
3560
|
+
const kept = typeof trimmed?.kept === "number" ? trimmed.kept : maxLines;
|
|
3561
|
+
return {
|
|
3562
|
+
compacted: true,
|
|
3563
|
+
method: "transcript-trim",
|
|
3564
|
+
detail: `kept ${kept} transcript events`,
|
|
3565
|
+
};
|
|
3566
|
+
}
|
|
3567
|
+
const trimReason = typeof trimmed?.reason === "string" ? trimmed.reason : undefined;
|
|
3568
|
+
return {
|
|
3569
|
+
compacted: false,
|
|
3570
|
+
method: "none",
|
|
3571
|
+
reason: trimReason || nativeReason || "transcript trim made no change",
|
|
3572
|
+
};
|
|
3535
3573
|
}
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3574
|
+
catch (err) {
|
|
3575
|
+
const trimReason = err.message;
|
|
3576
|
+
return {
|
|
3577
|
+
compacted: false,
|
|
3578
|
+
method: "none",
|
|
3579
|
+
reason: nativeReason
|
|
3580
|
+
? `semantic compaction: ${nativeReason}; transcript trim: ${trimReason}`
|
|
3581
|
+
: trimReason,
|
|
3582
|
+
};
|
|
3541
3583
|
}
|
|
3542
|
-
const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
|
|
3543
|
-
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)}M`;
|
|
3544
|
-
return {
|
|
3545
|
-
compacted: true,
|
|
3546
|
-
method: "tool-trim",
|
|
3547
|
-
detail: `${mb(before)}→${mb(after)} (-${pct}%)`,
|
|
3548
|
-
};
|
|
3549
3584
|
}
|
|
3550
3585
|
async handleCompactCommand(chatId, messageId) {
|
|
3551
3586
|
const sessionKey = this.getSessionKey(chatId);
|
|
3552
3587
|
const en = this.isEn(chatId);
|
|
3553
3588
|
// Large sessions can take tens of seconds to compact (native compaction may
|
|
3554
|
-
// even time out before the
|
|
3589
|
+
// even time out before the transcript-trim fallback kicks in). Show a ticking
|
|
3555
3590
|
// "compacting…" card so the user can SEE it is working instead of staring at
|
|
3556
3591
|
// nothing and assuming nothing is happening. The card is created lazily, so
|
|
3557
3592
|
// a fast compaction that finishes quickly never flashes a card at all.
|
|
@@ -3563,19 +3598,24 @@ export class FeishuBot {
|
|
|
3563
3598
|
progress.start();
|
|
3564
3599
|
try {
|
|
3565
3600
|
const r = await this.compactWithFallback(sessionKey, (phase) => {
|
|
3566
|
-
if (phase === "
|
|
3567
|
-
void progress.
|
|
3601
|
+
if (phase === "transcript-trim")
|
|
3602
|
+
void progress.toTranscriptTrim().catch(() => { });
|
|
3568
3603
|
});
|
|
3569
3604
|
if (r.compacted) {
|
|
3570
3605
|
// If a card was shown, patch it in place; otherwise (fast finish, no card)
|
|
3571
3606
|
// fall back to a normal reply so the user still gets confirmation.
|
|
3572
|
-
const
|
|
3573
|
-
|
|
3607
|
+
const localizedTrimDetail = r.detail
|
|
3608
|
+
?.replace("kept ", en ? "kept " : "保留最近 ")
|
|
3609
|
+
.replace(" transcript events", en ? " transcript events" : " 条转录事件");
|
|
3610
|
+
const detail = r.method === "transcript-trim" && localizedTrimDetail
|
|
3611
|
+
? (en ? `transcript trim: ${localizedTrimDetail}` : `转录裁剪:${localizedTrimDetail}`)
|
|
3574
3612
|
: (en ? "native" : "原生压缩");
|
|
3575
3613
|
await progress.done(detail);
|
|
3576
3614
|
if (!progress.id) {
|
|
3577
|
-
await this.replyMessage(messageId, r.method === "
|
|
3578
|
-
? (en
|
|
3615
|
+
await this.replyMessage(messageId, r.method === "transcript-trim"
|
|
3616
|
+
? (en
|
|
3617
|
+
? `✅ Session compacted (transcript trim${localizedTrimDetail ? ": " + localizedTrimDetail : ""})`
|
|
3618
|
+
: `✅ Session 已压缩(转录裁剪${localizedTrimDetail ? ":" + localizedTrimDetail : ""})`)
|
|
3579
3619
|
: (en ? `✅ Session compacted` : `✅ Session 已压缩`));
|
|
3580
3620
|
}
|
|
3581
3621
|
}
|