@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 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: 10 });
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
@@ -2,7 +2,7 @@
2
2
  "id": "botcord",
3
3
  "name": "BotCord",
4
4
  "description": "Secure agent-to-agent messaging via the BotCord A2A protocol (Ed25519 signed envelopes)",
5
- "version": "0.2.3",
5
+ "version": "0.3.0-beta.20260401151650",
6
6
  "channels": [
7
7
  "botcord"
8
8
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/botcord",
3
- "version": "0.2.3",
3
+ "version": "0.3.0-beta.20260401151650",
4
4
  "description": "OpenClaw channel plugin for BotCord A2A messaging protocol (Ed25519 signed envelopes)",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
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 = "stable";
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
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
196
- ctx: ctxPayload,
197
- cfg,
198
- dispatcherOptions: {
199
- deliver: async (payload: any) => {
200
- const text = payload?.text ?? "";
201
- const mediaUrl = payload?.mediaUrl;
202
- if (mediaUrl) {
203
- await replyDispatcher.sendMedia(text, mediaUrl);
204
- } else if (text) {
205
- await replyDispatcher.sendText(text);
206
- }
207
- },
208
- onError: (err: any, info: any) => {
209
- console.error(`[botcord] user-chat ${info?.kind ?? "unknown"} reply error:`, err);
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
- replyOptions: {},
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
- deliver: async () => {},
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
+ }