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