@botcord/botcord 0.2.3 → 0.3.0-beta.20260401151650
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/index.ts +57 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.ts +98 -0
- package/src/constants.ts +1 -1
- package/src/inbound.ts +226 -19
- package/src/memory-hook.ts +71 -0
- package/src/memory-protocol.ts +117 -0
- package/src/memory.ts +142 -0
- package/src/owner-chat-stream.ts +20 -0
- package/src/poller.ts +2 -11
- package/src/room-context.ts +269 -0
- package/src/tools/room-context.ts +139 -0
- package/src/ws-client.ts +24 -16
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { createMessagingTool, createUploadTool } from "./src/tools/messaging.js"
|
|
|
7
7
|
import { createRoomsTool } from "./src/tools/rooms.js";
|
|
8
8
|
import { createContactsTool } from "./src/tools/contacts.js";
|
|
9
9
|
import { createDirectoryTool } from "./src/tools/directory.js";
|
|
10
|
+
import { createRoomContextTool } from "./src/tools/room-context.js";
|
|
10
11
|
import { createTopicsTool } from "./src/tools/topics.js";
|
|
11
12
|
import { createAccountTool } from "./src/tools/account.js";
|
|
12
13
|
import { createPaymentTool } from "./src/tools/payment.js";
|
|
@@ -27,6 +28,9 @@ import {
|
|
|
27
28
|
recordBotCordOutboundText,
|
|
28
29
|
shouldRunBotCordLoopRiskCheck,
|
|
29
30
|
} from "./src/loop-risk.js";
|
|
31
|
+
import { buildRoomContextHookResult, clearSessionRoom } from "./src/room-context.js";
|
|
32
|
+
import { activeOwnerChatStreams } from "./src/owner-chat-stream.js";
|
|
33
|
+
import { buildWorkingMemoryHookResult } from "./src/memory-hook.js";
|
|
30
34
|
|
|
31
35
|
// Inline replacement for defineChannelPluginEntry from openclaw/plugin-sdk/core.
|
|
32
36
|
// Avoids missing dist artifacts in npm-installed openclaw (see openclaw#53685).
|
|
@@ -51,6 +55,7 @@ export default {
|
|
|
51
55
|
api.registerTool(createContactsTool() as any);
|
|
52
56
|
api.registerTool(createAccountTool() as any);
|
|
53
57
|
api.registerTool(createDirectoryTool() as any);
|
|
58
|
+
api.registerTool(createRoomContextTool() as any);
|
|
54
59
|
api.registerTool(createPaymentTool() as any);
|
|
55
60
|
api.registerTool(createSubscriptionTool() as any);
|
|
56
61
|
api.registerTool(createNotifyTool() as any);
|
|
@@ -60,6 +65,43 @@ export default {
|
|
|
60
65
|
|
|
61
66
|
// Hooks
|
|
62
67
|
api.on("after_tool_call", async (event: any, ctx: any) => {
|
|
68
|
+
// Stream tool blocks to Hub for active owner-chat sessions
|
|
69
|
+
const stream = activeOwnerChatStreams.get(ctx.sessionKey);
|
|
70
|
+
if (stream) {
|
|
71
|
+
try {
|
|
72
|
+
const toolName = ctx.toolName ?? "unknown";
|
|
73
|
+
const paramsSummary: Record<string, unknown> = {};
|
|
74
|
+
if (event.params && typeof event.params === "object") {
|
|
75
|
+
// Include only safe summary fields, not full payloads
|
|
76
|
+
for (const [k, v] of Object.entries(event.params)) {
|
|
77
|
+
paramsSummary[k] = typeof v === "string" && v.length > 200
|
|
78
|
+
? v.slice(0, 200) + "..."
|
|
79
|
+
: v;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
await stream.client.postStreamBlock(stream.traceId, stream.seq++, {
|
|
83
|
+
kind: "tool_call",
|
|
84
|
+
payload: { name: toolName, params: paramsSummary },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (event.result != null) {
|
|
88
|
+
const resultStr = typeof event.result === "string"
|
|
89
|
+
? event.result
|
|
90
|
+
: JSON.stringify(event.result);
|
|
91
|
+
await stream.client.postStreamBlock(stream.traceId, stream.seq++, {
|
|
92
|
+
kind: "tool_result",
|
|
93
|
+
payload: {
|
|
94
|
+
name: toolName,
|
|
95
|
+
result: resultStr.length > 500 ? resultStr.slice(0, 500) + "..." : resultStr,
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
} catch (err) {
|
|
100
|
+
console.warn("[botcord] owner-chat stream block error:", err);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Existing loop-risk tracking
|
|
63
105
|
if (ctx.toolName !== "botcord_send") return;
|
|
64
106
|
if (!didBotCordSendSucceed(event.result, event.error)) return;
|
|
65
107
|
recordBotCordOutboundText({
|
|
@@ -68,6 +110,19 @@ export default {
|
|
|
68
110
|
});
|
|
69
111
|
});
|
|
70
112
|
|
|
113
|
+
// Room context injection — highest priority among BotCord hooks, so its
|
|
114
|
+
// prependContext is placed farther from the user prompt.
|
|
115
|
+
api.on("before_prompt_build", async (_event: any, ctx: any) => {
|
|
116
|
+
return buildRoomContextHookResult(ctx.sessionKey);
|
|
117
|
+
}, { priority: 60 });
|
|
118
|
+
|
|
119
|
+
// Working memory injection — between room context and loop-risk.
|
|
120
|
+
api.on("before_prompt_build", async (_event: any, ctx: any) => {
|
|
121
|
+
return buildWorkingMemoryHookResult(ctx.sessionKey);
|
|
122
|
+
}, { priority: 50 });
|
|
123
|
+
|
|
124
|
+
// Loop-risk guard — lower priority = runs later, so its prependContext
|
|
125
|
+
// ends up closest to the user prompt where it's most effective.
|
|
71
126
|
api.on("before_prompt_build", async (event: any, ctx: any) => {
|
|
72
127
|
if (!shouldRunBotCordLoopRiskCheck({
|
|
73
128
|
channelId: ctx.channelId,
|
|
@@ -85,10 +140,11 @@ export default {
|
|
|
85
140
|
|
|
86
141
|
if (!prependContext) return;
|
|
87
142
|
return { prependContext };
|
|
88
|
-
}, { priority:
|
|
143
|
+
}, { priority: 50 });
|
|
89
144
|
|
|
90
145
|
api.on("session_end", async (_event: any, ctx: any) => {
|
|
91
146
|
clearBotCordLoopRiskSession(ctx.sessionKey);
|
|
147
|
+
clearSessionRoom(ctx.sessionKey);
|
|
92
148
|
});
|
|
93
149
|
|
|
94
150
|
// Commands
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -171,6 +171,24 @@ export class BotCordClient {
|
|
|
171
171
|
return data;
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// ── Stream blocks (owner-chat WS) ────────────────────────────
|
|
175
|
+
|
|
176
|
+
async postStreamBlock(
|
|
177
|
+
traceId: string,
|
|
178
|
+
seq: number,
|
|
179
|
+
block: { kind: string; payload: Record<string, unknown> },
|
|
180
|
+
): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
await this.hubFetch("/hub/stream-block", {
|
|
183
|
+
method: "POST",
|
|
184
|
+
body: JSON.stringify({ trace_id: traceId, seq, block }),
|
|
185
|
+
});
|
|
186
|
+
} catch (err) {
|
|
187
|
+
// Stream blocks are best-effort; log but don't throw
|
|
188
|
+
console.warn(`[botcord] postStreamBlock failed (trace=${traceId} seq=${seq}):`, err);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
174
192
|
// ── Messaging ─────────────────────────────────────────────────
|
|
175
193
|
|
|
176
194
|
async sendMessage(
|
|
@@ -764,6 +782,86 @@ export class BotCordClient {
|
|
|
764
782
|
return (await resp.json()) as Subscription;
|
|
765
783
|
}
|
|
766
784
|
|
|
785
|
+
// ── Room Context (retrieve / search) ──────────────────────────
|
|
786
|
+
|
|
787
|
+
async roomSummary(roomId: string, recentLimit?: number): Promise<any> {
|
|
788
|
+
const params = new URLSearchParams();
|
|
789
|
+
if (recentLimit) params.set("recent_limit", String(recentLimit));
|
|
790
|
+
const q = params.toString();
|
|
791
|
+
const resp = await this.hubFetch(`/hub/rooms/${roomId}/summary${q ? `?${q}` : ""}`);
|
|
792
|
+
return await resp.json();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
async roomMessages(
|
|
796
|
+
roomId: string,
|
|
797
|
+
opts?: {
|
|
798
|
+
limit?: number;
|
|
799
|
+
before?: string;
|
|
800
|
+
after?: string;
|
|
801
|
+
topicId?: string;
|
|
802
|
+
senderId?: string;
|
|
803
|
+
},
|
|
804
|
+
): Promise<any> {
|
|
805
|
+
const params = new URLSearchParams();
|
|
806
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
807
|
+
if (opts?.before) params.set("before", opts.before);
|
|
808
|
+
if (opts?.after) params.set("after", opts.after);
|
|
809
|
+
if (opts?.topicId) params.set("topic_id", opts.topicId);
|
|
810
|
+
if (opts?.senderId) params.set("sender_id", opts.senderId);
|
|
811
|
+
const q = params.toString();
|
|
812
|
+
const resp = await this.hubFetch(`/hub/rooms/${roomId}/messages${q ? `?${q}` : ""}`);
|
|
813
|
+
return await resp.json();
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
async roomSearch(
|
|
817
|
+
roomId: string,
|
|
818
|
+
query: string,
|
|
819
|
+
opts?: {
|
|
820
|
+
limit?: number;
|
|
821
|
+
before?: string;
|
|
822
|
+
topicId?: string;
|
|
823
|
+
senderId?: string;
|
|
824
|
+
},
|
|
825
|
+
): Promise<any> {
|
|
826
|
+
const params = new URLSearchParams();
|
|
827
|
+
params.set("q", query);
|
|
828
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
829
|
+
if (opts?.before) params.set("before", opts.before);
|
|
830
|
+
if (opts?.topicId) params.set("topic_id", opts.topicId);
|
|
831
|
+
if (opts?.senderId) params.set("sender_id", opts.senderId);
|
|
832
|
+
const resp = await this.hubFetch(`/hub/rooms/${roomId}/search?${params.toString()}`);
|
|
833
|
+
return await resp.json();
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async roomsOverview(limit?: number): Promise<any> {
|
|
837
|
+
const params = new URLSearchParams();
|
|
838
|
+
if (limit) params.set("limit", String(limit));
|
|
839
|
+
const q = params.toString();
|
|
840
|
+
const resp = await this.hubFetch(`/hub/rooms/overview${q ? `?${q}` : ""}`);
|
|
841
|
+
return await resp.json();
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async globalSearch(
|
|
845
|
+
query: string,
|
|
846
|
+
opts?: {
|
|
847
|
+
limit?: number;
|
|
848
|
+
roomId?: string;
|
|
849
|
+
topicId?: string;
|
|
850
|
+
senderId?: string;
|
|
851
|
+
before?: string;
|
|
852
|
+
},
|
|
853
|
+
): Promise<any> {
|
|
854
|
+
const params = new URLSearchParams();
|
|
855
|
+
params.set("q", query);
|
|
856
|
+
if (opts?.limit) params.set("limit", String(opts.limit));
|
|
857
|
+
if (opts?.roomId) params.set("room_id", opts.roomId);
|
|
858
|
+
if (opts?.topicId) params.set("topic_id", opts.topicId);
|
|
859
|
+
if (opts?.senderId) params.set("sender_id", opts.senderId);
|
|
860
|
+
if (opts?.before) params.set("before", opts.before);
|
|
861
|
+
const resp = await this.hubFetch(`/hub/search?${params.toString()}`);
|
|
862
|
+
return await resp.json();
|
|
863
|
+
}
|
|
864
|
+
|
|
767
865
|
// ── Invites ──────────────────────────────────────────────────
|
|
768
866
|
|
|
769
867
|
async previewInvite(code: string): Promise<any> {
|
package/src/constants.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
export type ReleaseChannel = "stable" | "beta";
|
|
11
11
|
|
|
12
|
-
export const RELEASE_CHANNEL: ReleaseChannel = "
|
|
12
|
+
export const RELEASE_CHANNEL: ReleaseChannel = "beta";
|
|
13
13
|
|
|
14
14
|
const HUB_URLS: Record<ReleaseChannel, string> = {
|
|
15
15
|
stable: "https://api.botcord.chat",
|
package/src/inbound.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { getBotCordRuntime } from "./runtime.js";
|
|
|
6
6
|
import { resolveAccountConfig } from "./config.js";
|
|
7
7
|
import { attachTokenPersistence } from "./credentials.js";
|
|
8
8
|
import { buildSessionKey } from "./session-key.js";
|
|
9
|
+
import { registerSessionRoom } from "./room-context.js";
|
|
10
|
+
import { processOutboundMemory } from "./memory-hook.js";
|
|
9
11
|
import { readFileSync } from "node:fs";
|
|
10
12
|
|
|
11
13
|
// Simplified inline replacement for loadSessionStore from openclaw/plugin-sdk/mattermost.
|
|
@@ -23,6 +25,7 @@ function loadSessionStore(storePath: string): Record<string, any> {
|
|
|
23
25
|
import { sanitizeUntrustedContent, sanitizeSenderName } from "./sanitize.js";
|
|
24
26
|
import { BotCordClient } from "./client.js";
|
|
25
27
|
import { createBotCordReplyDispatcher } from "./reply-dispatcher.js";
|
|
28
|
+
import { activeOwnerChatStreams } from "./owner-chat-stream.js";
|
|
26
29
|
import type { InboxMessage, MessageType } from "./types.js";
|
|
27
30
|
|
|
28
31
|
/** Normalize notifySession (string | string[] | undefined) to a flat array. */
|
|
@@ -91,6 +94,76 @@ export interface InboundParams {
|
|
|
91
94
|
mentioned?: boolean;
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Batch handler for InboxMessages — groups messages by session key and
|
|
99
|
+
* dispatches each group as a single combined message to OpenClaw.
|
|
100
|
+
* Same-session A2A messages are merged into one dispatch to avoid
|
|
101
|
+
* triggering multiple AI inference calls for the same conversation.
|
|
102
|
+
*
|
|
103
|
+
* Returns the hub_msg_ids of successfully handled messages.
|
|
104
|
+
*/
|
|
105
|
+
export async function handleInboxMessageBatch(
|
|
106
|
+
messages: InboxMessage[],
|
|
107
|
+
accountId: string,
|
|
108
|
+
cfg: any,
|
|
109
|
+
): Promise<string[]> {
|
|
110
|
+
if (messages.length === 0) return [];
|
|
111
|
+
|
|
112
|
+
// Separate dashboard user chat messages (not batchable — single fixed session)
|
|
113
|
+
// and group A2A messages by computed session key.
|
|
114
|
+
const dashboardMsgs: InboxMessage[] = [];
|
|
115
|
+
const a2aGroups = new Map<string, InboxMessage[]>();
|
|
116
|
+
|
|
117
|
+
for (const msg of messages) {
|
|
118
|
+
if (msg.source_type === "dashboard_user_chat") {
|
|
119
|
+
dashboardMsgs.push(msg);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const envelope = msg.envelope;
|
|
123
|
+
const senderId = envelope.from || "unknown";
|
|
124
|
+
const roomId = msg.room_id;
|
|
125
|
+
const topic = msg.topic;
|
|
126
|
+
const key = buildSessionKey(roomId, topic, senderId);
|
|
127
|
+
// For group rooms, use roomId+topic as the group key (ignoring sender)
|
|
128
|
+
// so messages from different senders in the same room are batched.
|
|
129
|
+
const isGroupRoom = !!roomId && !roomId.startsWith("rm_dm_");
|
|
130
|
+
const groupKey = isGroupRoom
|
|
131
|
+
? buildSessionKey(roomId, topic)
|
|
132
|
+
: key;
|
|
133
|
+
const group = a2aGroups.get(groupKey) || [];
|
|
134
|
+
group.push(msg);
|
|
135
|
+
a2aGroups.set(groupKey, group);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const handledIds: string[] = [];
|
|
139
|
+
|
|
140
|
+
// Handle dashboard user chat messages one by one (they share a fixed session)
|
|
141
|
+
for (const msg of dashboardMsgs) {
|
|
142
|
+
try {
|
|
143
|
+
await handleInboxMessage(msg, accountId, cfg);
|
|
144
|
+
handledIds.push(msg.hub_msg_id);
|
|
145
|
+
} catch {
|
|
146
|
+
// Error logged inside handleInboxMessage
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Handle A2A groups — single messages dispatched normally, batches merged
|
|
151
|
+
for (const [, group] of a2aGroups) {
|
|
152
|
+
try {
|
|
153
|
+
if (group.length === 1) {
|
|
154
|
+
await handleA2AMessage(group[0], accountId, cfg);
|
|
155
|
+
} else {
|
|
156
|
+
await handleA2AMessageBatch(group, accountId, cfg);
|
|
157
|
+
}
|
|
158
|
+
for (const msg of group) handledIds.push(msg.hub_msg_id);
|
|
159
|
+
} catch {
|
|
160
|
+
// Error logged inside handlers
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return handledIds;
|
|
165
|
+
}
|
|
166
|
+
|
|
94
167
|
/**
|
|
95
168
|
* Shared handler for InboxMessage — used by both WebSocket and Poller paths.
|
|
96
169
|
* Normalizes InboxMessage into InboundParams and dispatches to OpenClaw.
|
|
@@ -190,27 +263,56 @@ async function handleDashboardUserChat(
|
|
|
190
263
|
replyTarget,
|
|
191
264
|
});
|
|
192
265
|
|
|
266
|
+
// Register owner-chat stream so after_tool_call hook can stream blocks
|
|
267
|
+
const effectiveSessionKey = route.sessionKey || sessionKey;
|
|
268
|
+
const traceId = msg.hub_msg_id;
|
|
269
|
+
if (traceId) {
|
|
270
|
+
activeOwnerChatStreams.set(effectiveSessionKey, {
|
|
271
|
+
traceId,
|
|
272
|
+
client,
|
|
273
|
+
seq: 1,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
193
277
|
// Use buffered block dispatcher with auto-delivery to the chat room.
|
|
194
278
|
// The deliver callback receives a ReplyPayload object (not a plain string).
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
279
|
+
// Memory extraction: strip <memory_update> blocks and persist before sending.
|
|
280
|
+
try {
|
|
281
|
+
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
282
|
+
ctx: ctxPayload,
|
|
283
|
+
cfg,
|
|
284
|
+
dispatcherOptions: {
|
|
285
|
+
deliver: async (payload: any) => {
|
|
286
|
+
const rawText = payload?.text ?? "";
|
|
287
|
+
const text = processOutboundMemory(rawText, sessionKey);
|
|
288
|
+
const mediaUrl = payload?.mediaUrl;
|
|
289
|
+
|
|
290
|
+
// Stream assistant block to Hub before sending the final reply
|
|
291
|
+
if (traceId && text) {
|
|
292
|
+
const stream = activeOwnerChatStreams.get(effectiveSessionKey);
|
|
293
|
+
if (stream) {
|
|
294
|
+
await client.postStreamBlock(traceId, stream.seq++, {
|
|
295
|
+
kind: "assistant",
|
|
296
|
+
payload: { text },
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (mediaUrl) {
|
|
302
|
+
await replyDispatcher.sendMedia(text, mediaUrl);
|
|
303
|
+
} else if (text) {
|
|
304
|
+
await replyDispatcher.sendText(text);
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
onError: (err: any, info: any) => {
|
|
308
|
+
console.error(`[botcord] user-chat ${info?.kind ?? "unknown"} reply error:`, err);
|
|
309
|
+
},
|
|
210
310
|
},
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
311
|
+
replyOptions: {},
|
|
312
|
+
});
|
|
313
|
+
} finally {
|
|
314
|
+
activeOwnerChatStreams.delete(effectiveSessionKey);
|
|
315
|
+
}
|
|
214
316
|
}
|
|
215
317
|
|
|
216
318
|
/**
|
|
@@ -274,6 +376,89 @@ async function handleA2AMessage(
|
|
|
274
376
|
});
|
|
275
377
|
}
|
|
276
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Handle a batch of A2A messages that share the same session (room + topic).
|
|
381
|
+
* Combines individual <agent-message> blocks into a single dispatch.
|
|
382
|
+
*/
|
|
383
|
+
async function handleA2AMessageBatch(
|
|
384
|
+
msgs: InboxMessage[],
|
|
385
|
+
accountId: string,
|
|
386
|
+
cfg: any,
|
|
387
|
+
): Promise<void> {
|
|
388
|
+
// Use the first message for shared room context
|
|
389
|
+
const first = msgs[0];
|
|
390
|
+
const isGroupRoom = !!first.room_id && !first.room_id.startsWith("rm_dm_");
|
|
391
|
+
const chatType = isGroupRoom ? "group" : "direct";
|
|
392
|
+
const roomName = isGroupRoom ? (first.room_name || first.room_id) : undefined;
|
|
393
|
+
|
|
394
|
+
// Build individual <agent-message> blocks for each message
|
|
395
|
+
const messageBlocks: string[] = [];
|
|
396
|
+
let anyMentioned = false;
|
|
397
|
+
let hasContactRequest = false;
|
|
398
|
+
let contactRequestSender = "";
|
|
399
|
+
|
|
400
|
+
for (const msg of msgs) {
|
|
401
|
+
const envelope = msg.envelope;
|
|
402
|
+
const senderId = envelope.from || "unknown";
|
|
403
|
+
const rawContent =
|
|
404
|
+
msg.text ||
|
|
405
|
+
(typeof envelope.payload === "string"
|
|
406
|
+
? envelope.payload
|
|
407
|
+
: (envelope.payload?.text as string) ?? JSON.stringify(envelope.payload));
|
|
408
|
+
|
|
409
|
+
const sanitizedSender = sanitizeSenderName(senderId);
|
|
410
|
+
const sanitizedContent = sanitizeUntrustedContent(rawContent);
|
|
411
|
+
messageBlocks.push(
|
|
412
|
+
`<agent-message sender="${sanitizedSender}">\n${sanitizedContent}\n</agent-message>`,
|
|
413
|
+
);
|
|
414
|
+
|
|
415
|
+
if (msg.mentioned) anyMentioned = true;
|
|
416
|
+
if (envelope.type === "contact_request") {
|
|
417
|
+
hasContactRequest = true;
|
|
418
|
+
contactRequestSender = sanitizedSender;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Shared header — indicate batch count
|
|
423
|
+
const header = `[BotCord Messages (${msgs.length} new)]` +
|
|
424
|
+
(roomName ? ` | room: ${roomName}` : "") +
|
|
425
|
+
` | to: ${accountId}`;
|
|
426
|
+
|
|
427
|
+
const silentHint =
|
|
428
|
+
chatType === "group"
|
|
429
|
+
? '\n\n[In group chats, do NOT reply unless you are explicitly mentioned or addressed. If no response is needed, reply with exactly "NO_REPLY" and nothing else.]'
|
|
430
|
+
: '\n\n[If the conversation has naturally concluded or no response is needed, reply with exactly "NO_REPLY" and nothing else.]';
|
|
431
|
+
|
|
432
|
+
const notifyOwnerHint = hasContactRequest
|
|
433
|
+
? `\n\n[You received a contact request from ${contactRequestSender}. Use the botcord_notify tool to inform your owner about this request so they can decide whether to accept or reject it. Include the sender's agent ID and any message they attached.]`
|
|
434
|
+
: "";
|
|
435
|
+
|
|
436
|
+
const content = `${header}\n${messageBlocks.join("\n")}${silentHint}${notifyOwnerHint}`;
|
|
437
|
+
const contentWithRule = isGroupRoom ? appendRoomRule(content, first.room_rule) : content;
|
|
438
|
+
|
|
439
|
+
// Use the last message's metadata for dispatch (most recent)
|
|
440
|
+
const last = msgs[msgs.length - 1];
|
|
441
|
+
const lastEnvelope = last.envelope;
|
|
442
|
+
const lastSenderId = lastEnvelope.from || "unknown";
|
|
443
|
+
|
|
444
|
+
await dispatchInbound({
|
|
445
|
+
cfg,
|
|
446
|
+
accountId,
|
|
447
|
+
senderName: lastSenderId,
|
|
448
|
+
senderId: lastSenderId,
|
|
449
|
+
content: contentWithRule,
|
|
450
|
+
messageId: lastEnvelope.msg_id,
|
|
451
|
+
messageType: lastEnvelope.type,
|
|
452
|
+
chatType,
|
|
453
|
+
groupSubject: isGroupRoom ? (first.room_name || first.room_id) : undefined,
|
|
454
|
+
replyTarget: isGroupRoom ? first.room_id! : (lastEnvelope.from || ""),
|
|
455
|
+
roomId: first.room_id,
|
|
456
|
+
topic: first.topic,
|
|
457
|
+
topicId: first.topic_id,
|
|
458
|
+
mentioned: anyMentioned,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
277
462
|
/**
|
|
278
463
|
* Dispatch an inbound message into OpenClaw's channel routing system.
|
|
279
464
|
*/
|
|
@@ -307,6 +492,24 @@ export async function dispatchInbound(params: InboundParams): Promise<void> {
|
|
|
307
492
|
},
|
|
308
493
|
});
|
|
309
494
|
|
|
495
|
+
// Track session → room mapping for cross-session context injection.
|
|
496
|
+
// Register under the *effective* session key (what OpenClaw passes as
|
|
497
|
+
// ctx.sessionKey in hooks). When routing overrides the key, use that;
|
|
498
|
+
// otherwise fall back to the deterministic BotCord key.
|
|
499
|
+
// Note: if routing merges multiple rooms into one session, last-writer-wins
|
|
500
|
+
// is intentional — the session context already mixes messages from all rooms.
|
|
501
|
+
// Also register DM sessions without a roomId so they appear in digests.
|
|
502
|
+
const effectiveSessionKey = route.sessionKey || sessionKey;
|
|
503
|
+
const peerId = roomId || senderId;
|
|
504
|
+
if (peerId) {
|
|
505
|
+
registerSessionRoom(effectiveSessionKey, {
|
|
506
|
+
roomId: roomId || `rm_dm_${senderId}`,
|
|
507
|
+
roomName: groupSubject || roomId || senderName,
|
|
508
|
+
accountId,
|
|
509
|
+
lastActivityAt: Date.now(),
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
310
513
|
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
311
514
|
const formattedBody = core.channel.reply.formatAgentEnvelope({
|
|
312
515
|
channel: "BotCord",
|
|
@@ -348,7 +551,11 @@ export async function dispatchInbound(params: InboundParams): Promise<void> {
|
|
|
348
551
|
dispatcherOptions: {
|
|
349
552
|
// A2A replies are sent explicitly via botcord_send tool.
|
|
350
553
|
// Suppress automatic delivery to avoid leaking agent narration.
|
|
351
|
-
|
|
554
|
+
// Still extract <memory_update> blocks from the suppressed text.
|
|
555
|
+
deliver: async (payload: any) => {
|
|
556
|
+
const rawText = payload?.text ?? "";
|
|
557
|
+
if (rawText) processOutboundMemory(rawText, effectiveSessionKey);
|
|
558
|
+
},
|
|
352
559
|
onError: (err: any, info: any) => {
|
|
353
560
|
console.error(`[botcord] ${info?.kind ?? "unknown"} reply error:`, err);
|
|
354
561
|
},
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Memory hook — glue between OpenClaw hooks and the memory subsystem.
|
|
3
|
+
*
|
|
4
|
+
* - buildWorkingMemoryHookResult(): before_prompt_build handler
|
|
5
|
+
* - processOutboundMemory(): extract <memory_update> from outbound text,
|
|
6
|
+
* persist to disk, return cleaned text
|
|
7
|
+
*/
|
|
8
|
+
import { readWorkingMemory, writeWorkingMemory } from "./memory.js";
|
|
9
|
+
import { buildWorkingMemoryPrompt, extractMemoryUpdate } from "./memory-protocol.js";
|
|
10
|
+
import { getSessionRoom } from "./room-context.js";
|
|
11
|
+
|
|
12
|
+
// ── before_prompt_build handler ────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the working memory hook result for injection into the agent prompt.
|
|
16
|
+
* Returns prependContext so it appears close to the user message.
|
|
17
|
+
*/
|
|
18
|
+
export async function buildWorkingMemoryHookResult(
|
|
19
|
+
sessionKey: string | undefined,
|
|
20
|
+
): Promise<{ prependContext?: string } | null> {
|
|
21
|
+
if (!sessionKey) return null;
|
|
22
|
+
|
|
23
|
+
// Inject for registered BotCord sessions and the owner-chat session.
|
|
24
|
+
// Owner-chat uses a fixed key and is never registered via inbound dispatch,
|
|
25
|
+
// but still needs working memory for cross-session continuity.
|
|
26
|
+
const isOwnerChat = sessionKey === "botcord:owner:main";
|
|
27
|
+
if (!isOwnerChat && !getSessionRoom(sessionKey)) return null;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const wm = readWorkingMemory();
|
|
31
|
+
const prompt = buildWorkingMemoryPrompt({ workingMemory: wm });
|
|
32
|
+
return { prependContext: prompt };
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
console.warn("[botcord] memory-hook: failed to read working memory:", err?.message ?? err);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Outbound memory extraction ─────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Process outbound text for <memory_update> blocks.
|
|
43
|
+
*
|
|
44
|
+
* - Extracts the memory content and persists to working-memory.json
|
|
45
|
+
* - Returns the cleaned text (without <memory_update> blocks)
|
|
46
|
+
*
|
|
47
|
+
* Safe to call on any text — returns the original if no memory blocks found.
|
|
48
|
+
*/
|
|
49
|
+
export function processOutboundMemory(
|
|
50
|
+
text: string,
|
|
51
|
+
sessionKey?: string,
|
|
52
|
+
): string {
|
|
53
|
+
if (!text) return text;
|
|
54
|
+
|
|
55
|
+
const { cleanedText, memoryContent } = extractMemoryUpdate(text);
|
|
56
|
+
|
|
57
|
+
if (memoryContent !== null) {
|
|
58
|
+
try {
|
|
59
|
+
writeWorkingMemory({
|
|
60
|
+
version: 1,
|
|
61
|
+
content: memoryContent,
|
|
62
|
+
updatedAt: new Date().toISOString(),
|
|
63
|
+
sourceSessionKey: sessionKey,
|
|
64
|
+
});
|
|
65
|
+
} catch (err: any) {
|
|
66
|
+
console.error("[botcord] memory-hook: failed to write working memory:", err?.message ?? err);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return cleanedText;
|
|
71
|
+
}
|