@mutirolabs/openclaw-brain 0.1.0

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/src/inbound.ts ADDED
@@ -0,0 +1,151 @@
1
+ // Translates bridge observed messages into the inbound delivery the OpenClaw
2
+ // gateway expects from a channel plugin. The runtime installs a single inbound
3
+ // delivery callback during plugin startup; this module's job is to shape each
4
+ // observed bridge envelope into that callback's input.
5
+
6
+ import { saveMediaBuffer } from "openclaw/plugin-sdk/browser-setup-tools";
7
+
8
+ import type { ObservedTurn } from "./bridge-protocol.js";
9
+ import { buildObservedTurn, isSelfEventMessage } from "./bridge-messages.js";
10
+
11
+ export type InboundMessage = {
12
+ channelId: "mutiro";
13
+ accountId: string;
14
+ conversationId: string;
15
+ messageId: string;
16
+ replyToMessageId?: string;
17
+ senderUsername: string;
18
+ text: string;
19
+ rawMessage: unknown;
20
+ attachmentContext?: string;
21
+ mediaPaths?: string[];
22
+ mediaTypes?: string[];
23
+ };
24
+
25
+ export type InboundDeliver = (message: InboundMessage) => Promise<void> | void;
26
+
27
+ export type InboundRoute = {
28
+ accountId: string;
29
+ agentUsername: string;
30
+ deliver: InboundDeliver;
31
+ };
32
+
33
+ type BridgeImage = {
34
+ data?: string;
35
+ mime_type?: string;
36
+ filename?: string;
37
+ };
38
+
39
+ /**
40
+ * The bridge delivers image attachments inline as base64 under
41
+ * `envelope.payload.images`. We persist each one into OpenClaw's media
42
+ * directory via `saveMediaBuffer`, which stages under `~/.openclaw/media/`
43
+ * — the only root OpenClaw's sandbox staging policy accepts. Writing to
44
+ * `os.tmpdir()` silently fails staging and leaves the agent with only a
45
+ * text path reference, so the bytes never reach the model.
46
+ */
47
+ const persistInlineImages = async (
48
+ images: BridgeImage[] | undefined,
49
+ ): Promise<{ paths: string[]; types: string[] }> => {
50
+ if (!Array.isArray(images) || images.length === 0) {
51
+ return { paths: [], types: [] };
52
+ }
53
+
54
+ const paths: string[] = [];
55
+ const types: string[] = [];
56
+ for (const entry of images) {
57
+ if (!entry?.data) continue;
58
+ try {
59
+ const saved = await saveMediaBuffer(
60
+ Buffer.from(entry.data, "base64"),
61
+ entry.mime_type,
62
+ "inbound",
63
+ undefined,
64
+ entry.filename,
65
+ );
66
+ paths.push(saved.path);
67
+ types.push(saved.contentType ?? entry.mime_type ?? "application/octet-stream");
68
+ } catch {
69
+ // Best-effort: skip a single bad attachment rather than aborting the turn.
70
+ }
71
+ }
72
+ return { paths, types };
73
+ };
74
+
75
+ /**
76
+ * Shapes a bridge envelope into the OpenClaw inbound message the gateway
77
+ * delivers to core. Returns the observed turn it extracted (useful for the
78
+ * caller to key session state) or null when the envelope was self-authored or
79
+ * did not carry a deliverable message.
80
+ */
81
+ export const deliverObservedEnvelope = async (
82
+ envelope: {
83
+ type?: string;
84
+ payload?: {
85
+ message?: unknown;
86
+ attachment_context?: string;
87
+ images?: BridgeImage[];
88
+ };
89
+ } & Parameters<typeof buildObservedTurn>[0],
90
+ route: InboundRoute,
91
+ ): Promise<ObservedTurn | null> => {
92
+ if (envelope.type === "event.message" && isSelfEventMessage(envelope, route.agentUsername)) {
93
+ return null;
94
+ }
95
+
96
+ // Reactions and other bare events are now delivered to OpenClaw like any
97
+ // other observation. The agent may choose to stay silent, which today
98
+ // surfaces "Agent couldn't generate a response" — acceptable trade-off
99
+ // for letting the agent actually see reactions happen. If OpenClaw grows
100
+ // a "silent turn is ok" dispatch option later, filter these through that.
101
+ const turn = buildObservedTurn(envelope);
102
+ if (!turn) {
103
+ return null;
104
+ }
105
+
106
+ const { paths, types } = await persistInlineImages(envelope.payload?.images);
107
+
108
+ // attachment_context is the host's narrative of downloaded attachments:
109
+ // [SYSTEM: Downloaded 1 file(s) to your workspace:
110
+ // • nf-2976.pdf → /Users/.../Downloads/nf-2976.pdf (PDF, 450 KB)]
111
+ // For PDFs/docs this is the ONLY pointer the agent gets — it needs the
112
+ // path to invoke `read` or a doc-extraction tool. We earlier stripped
113
+ // this string because the host's image probe produced noisy "0x0 pixels"
114
+ // metadata that fooled vision models. Image turns now route bytes
115
+ // through MediaPaths instead, so attachment_context is pure signal for
116
+ // non-image attachments. Append only when the message carries file-type
117
+ // parts; skip for image-only or text-only turns.
118
+ const hasFileParts = extractHasFileParts(
119
+ envelope.payload?.message as { parts?: Array<{ type?: string }> } | undefined,
120
+ );
121
+ const contextText = (envelope.payload?.attachment_context ?? "").trim();
122
+ const bodyText =
123
+ hasFileParts && contextText
124
+ ? turn.text
125
+ ? `${turn.text}\n\n${contextText}`
126
+ : contextText
127
+ : turn.text;
128
+
129
+ await route.deliver({
130
+ channelId: "mutiro",
131
+ accountId: route.accountId,
132
+ conversationId: turn.conversationId,
133
+ messageId: turn.messageId,
134
+ replyToMessageId: turn.replyToMessageId,
135
+ senderUsername: turn.senderUsername,
136
+ text: bodyText,
137
+ rawMessage: envelope.payload?.message,
138
+ attachmentContext: envelope.payload?.attachment_context,
139
+ ...(paths.length > 0 ? { mediaPaths: paths, mediaTypes: types } : {}),
140
+ });
141
+
142
+ return turn;
143
+ };
144
+
145
+ const extractHasFileParts = (
146
+ message: { parts?: Array<{ type?: string }> } | undefined,
147
+ ): boolean => {
148
+ if (!message) return false;
149
+ const parts = Array.isArray(message.parts) ? message.parts : [];
150
+ return parts.some((part) => part?.type === "file");
151
+ };
@@ -0,0 +1,210 @@
1
+ // Builds the live-handoff snapshot the host requests when a voice call
2
+ // starts. This is the brain's chance to hand OpenClaw's agent persona,
3
+ // the real session transcript, and channel-owned tool hints to Mutiro's
4
+ // live voice model so the call doesn't start with a generic LLM.
5
+ //
6
+ // The three fields returned map 1:1 to ChatBridgeSessionSnapshotResult:
7
+ // system_instruction ← agent's systemPromptOverride (or a minimal header)
8
+ // recent_messages ← recent turns parsed from OpenClaw's session jsonl
9
+ // tool_hints ← channel-owned agent tools advertised by the plugin
10
+
11
+ import { promises as fs } from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
15
+ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
16
+ import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
17
+
18
+ import type { LiveSnapshot, LiveToolHint } from "./bridge-session.js";
19
+
20
+ const MAX_RECENT_TRANSCRIPT_TURNS = 30;
21
+
22
+ type AgentConfigLike = {
23
+ name?: string;
24
+ systemPromptOverride?: string;
25
+ };
26
+
27
+ type SessionEntryLike = {
28
+ sessionId?: string;
29
+ sessionFile?: string;
30
+ };
31
+
32
+ type SessionStoreRecord = Record<string, SessionEntryLike>;
33
+
34
+ const resolveAgentConfig = (
35
+ cfg: OpenClawConfig,
36
+ agentId: string,
37
+ ): AgentConfigLike | undefined => {
38
+ const agents = (cfg as { agents?: { agents?: Record<string, AgentConfigLike> } }).agents;
39
+ return agents?.agents?.[agentId];
40
+ };
41
+
42
+ const buildSystemInstruction = (params: {
43
+ agentId: string;
44
+ agentConfig: AgentConfigLike | undefined;
45
+ agentUsername: string;
46
+ callerUsername?: string;
47
+ }): string => {
48
+ const override = params.agentConfig?.systemPromptOverride?.trim();
49
+ if (override) {
50
+ return override;
51
+ }
52
+ const displayName = params.agentConfig?.name?.trim() || params.agentId;
53
+ const callerSuffix = params.callerUsername ? ` Caller: @${params.callerUsername}.` : "";
54
+ return [
55
+ `You are ${displayName}, the same agent this user speaks with in chat.`,
56
+ "You are now speaking live over voice.",
57
+ "Stay concise, conversational, and in the agent's established persona.",
58
+ `Agent identity: @${params.agentUsername}.${callerSuffix}`,
59
+ ].join(" ");
60
+ };
61
+
62
+ type JsonlLine = {
63
+ type?: string;
64
+ message?: {
65
+ role?: string;
66
+ content?: Array<{ type?: string; text?: string; thinking?: string }>;
67
+ };
68
+ };
69
+
70
+ type JsonlContent = NonNullable<NonNullable<JsonlLine["message"]>["content"]>;
71
+
72
+ const extractTextFromContent = (content: JsonlContent | undefined): string => {
73
+ if (!Array.isArray(content)) return "";
74
+ const parts: string[] = [];
75
+ for (const entry of content) {
76
+ if (!entry || typeof entry !== "object") continue;
77
+ if (entry.type === "text" && typeof entry.text === "string") {
78
+ parts.push(entry.text);
79
+ }
80
+ }
81
+ return parts.join("").trim();
82
+ };
83
+
84
+ const readRecentTranscriptTurns = async (
85
+ sessionFile: string,
86
+ conversationId: string,
87
+ agentUsername: string,
88
+ ): Promise<unknown[]> => {
89
+ const raw = await fs.readFile(sessionFile, "utf8");
90
+ const lines = raw.split(/\r?\n/);
91
+ const turns: unknown[] = [];
92
+ for (const line of lines) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed) continue;
95
+ let parsed: JsonlLine;
96
+ try {
97
+ parsed = JSON.parse(trimmed) as JsonlLine;
98
+ } catch {
99
+ continue;
100
+ }
101
+ if (parsed.type !== "message") continue;
102
+ const role = parsed.message?.role;
103
+ if (role !== "user" && role !== "assistant") continue;
104
+ const text = extractTextFromContent(parsed.message?.content);
105
+ if (!text) continue;
106
+ const fromUsername = role === "assistant" ? agentUsername : "user";
107
+ turns.push({
108
+ id: `transcript-${turns.length}`,
109
+ conversation_id: conversationId,
110
+ from: { username: fromUsername },
111
+ text,
112
+ });
113
+ }
114
+ return turns.slice(-MAX_RECENT_TRANSCRIPT_TURNS);
115
+ };
116
+
117
+ const readRecentMessagesFromStore = async (params: {
118
+ cfg: OpenClawConfig;
119
+ agentId: string;
120
+ sessionKey: string;
121
+ conversationId: string;
122
+ agentUsername: string;
123
+ }): Promise<unknown[] | undefined> => {
124
+ try {
125
+ const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
126
+ const { loadSessionStore } = await import("openclaw/plugin-sdk/config-runtime");
127
+ const store = loadSessionStore(storePath) as SessionStoreRecord;
128
+ const entry = store[params.sessionKey];
129
+ if (!entry?.sessionId) return undefined;
130
+ const sessionFile =
131
+ entry.sessionFile && entry.sessionFile.trim()
132
+ ? entry.sessionFile
133
+ : path.join(path.dirname(storePath), `${entry.sessionId}.jsonl`);
134
+ return await readRecentTranscriptTurns(sessionFile, params.conversationId, params.agentUsername);
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ };
139
+
140
+ const buildToolHints = async (): Promise<LiveToolHint[]> => {
141
+ // Lazy import so the light startup path stays clean.
142
+ const { mutiroAgentTools } = await import("./agent-tools.js");
143
+ return mutiroAgentTools().map((tool) => {
144
+ const rawDescription = tool.description;
145
+ const description = typeof rawDescription === "string" ? rawDescription.trim() : "";
146
+ return {
147
+ name: tool.name,
148
+ description,
149
+ metadata: {},
150
+ };
151
+ });
152
+ };
153
+
154
+ export type LiveSnapshotContext = {
155
+ cfg: OpenClawConfig;
156
+ accountId: string;
157
+ conversationId: string;
158
+ callerUsername?: string;
159
+ callId?: string;
160
+ agentUsername: string;
161
+ };
162
+
163
+ /**
164
+ * Assemble everything the live voice model needs to speak as OpenClaw's
165
+ * agent. Safe to call even when config/session state is incomplete: each
166
+ * section degrades independently (missing agent config → minimal header,
167
+ * missing transcript → undefined recent_messages, tool enumeration always
168
+ * succeeds).
169
+ */
170
+ export const buildLiveSnapshot = async (
171
+ ctx: LiveSnapshotContext,
172
+ ): Promise<LiveSnapshot> => {
173
+ const route = resolveAgentRoute({
174
+ cfg: ctx.cfg,
175
+ channel: "mutiro",
176
+ accountId: ctx.accountId,
177
+ peer: { kind: "direct", id: ctx.callerUsername ?? "unknown" },
178
+ });
179
+ const agentConfig = resolveAgentConfig(ctx.cfg, route.agentId);
180
+
181
+ const systemInstruction = buildSystemInstruction({
182
+ agentId: route.agentId,
183
+ agentConfig,
184
+ agentUsername: ctx.agentUsername,
185
+ callerUsername: ctx.callerUsername,
186
+ });
187
+
188
+ const recentMessages = await readRecentMessagesFromStore({
189
+ cfg: ctx.cfg,
190
+ agentId: route.agentId,
191
+ sessionKey: route.sessionKey,
192
+ conversationId: ctx.conversationId,
193
+ agentUsername: ctx.agentUsername,
194
+ });
195
+
196
+ const toolHints = await buildToolHints();
197
+
198
+ const metadata: Record<string, string> = {
199
+ agent_id: route.agentId,
200
+ session_key: route.sessionKey,
201
+ };
202
+ if (ctx.callId) metadata.call_id = ctx.callId;
203
+
204
+ return {
205
+ systemInstruction,
206
+ ...(recentMessages ? { recentMessages } : {}),
207
+ toolHints,
208
+ metadata,
209
+ };
210
+ };
@@ -0,0 +1,326 @@
1
+ // Outbound adapter that translates OpenClaw reply-dispatch calls into
2
+ // bridge-local commands. Mirrors pi-brain's tool surface (send_message,
3
+ // send_voice_message, send_card, react_to_message, send_file_message,
4
+ // forward_message, recall, recall_get) but reshaped so OpenClaw's
5
+ // ChannelOutboundAdapter is the consumer instead of a Pi tool runtime.
6
+
7
+ import * as path from "node:path";
8
+
9
+ import type { BridgeClient } from "./bridge-client.js";
10
+ import { applyVoiceLanguage, normalizeOutputText } from "./bridge-messages.js";
11
+ import { TYPE_URLS } from "./bridge-protocol.js";
12
+
13
+ export type MutiroOutboundTarget = {
14
+ conversationId: string;
15
+ replyToMessageId: string;
16
+ };
17
+
18
+ export type MutiroOutbound = {
19
+ sendText: (
20
+ target: MutiroOutboundTarget,
21
+ text: string,
22
+ ) => Promise<unknown>;
23
+ sendVoice: (
24
+ target: MutiroOutboundTarget,
25
+ params: { toUsername: string; speech: string; language?: string },
26
+ ) => Promise<unknown>;
27
+ sendCard: (
28
+ target: MutiroOutboundTarget,
29
+ params: { components: unknown[]; data?: Record<string, unknown>; cardId?: string },
30
+ ) => Promise<unknown>;
31
+ sendCardJsonl: (
32
+ target: MutiroOutboundTarget,
33
+ params: { jsonl: string; version?: string; cardId?: string },
34
+ ) => Promise<unknown>;
35
+ sendFile: (
36
+ target: MutiroOutboundTarget,
37
+ params: { filePath: string; caption?: string },
38
+ ) => Promise<unknown>;
39
+ react: (params: { messageId: string; emoji: string }) => Promise<unknown>;
40
+ forward: (params: {
41
+ messageId: string;
42
+ // Destination is a `oneof` in ForwardMessageRequest: exactly one of
43
+ // `targetConversationId` or `toUsername` must be set.
44
+ targetConversationId?: string;
45
+ toUsername?: string;
46
+ comment?: string;
47
+ }) => Promise<unknown>;
48
+ recallSearch: (params: {
49
+ query: string;
50
+ conversationId?: string;
51
+ maxResults?: number;
52
+ }) => Promise<unknown>;
53
+ recallGet: (params: { entryId: string; conversationId?: string }) => Promise<unknown>;
54
+ endTurn: (target: MutiroOutboundTarget) => void;
55
+ emitSignal: (
56
+ target: MutiroOutboundTarget,
57
+ signalType: string,
58
+ detailText?: string,
59
+ ) => void;
60
+ };
61
+
62
+ const DEFAULT_VOICE = "en-US-Chirp3-HD-Orus";
63
+
64
+ const buildCardJson = (
65
+ components: Array<Record<string, unknown>>,
66
+ data?: Record<string, unknown>,
67
+ cardId?: string,
68
+ ) => {
69
+ let rootId = (components[0] as { id?: string } | undefined)?.id || "root";
70
+ for (const component of components) {
71
+ const c = component as { parentId?: string; parent_id?: string; id?: string };
72
+ if (!c.parentId && !c.parent_id) {
73
+ rootId = c.id || rootId;
74
+ break;
75
+ }
76
+ }
77
+
78
+ const lines: string[] = [
79
+ JSON.stringify({
80
+ surfaceUpdate: {
81
+ surfaceId: "main",
82
+ components,
83
+ clearBefore: true,
84
+ },
85
+ }),
86
+ ];
87
+
88
+ if (data) {
89
+ const contents = Object.keys(data).map((key) => ({
90
+ key,
91
+ valueString:
92
+ typeof data[key] === "object" ? JSON.stringify(data[key]) : String(data[key]),
93
+ }));
94
+ lines.push(
95
+ JSON.stringify({
96
+ dataModelUpdate: {
97
+ surfaceId: "main",
98
+ contents,
99
+ },
100
+ }),
101
+ );
102
+ }
103
+
104
+ lines.push(
105
+ JSON.stringify({
106
+ beginRendering: {
107
+ surfaceId: "main",
108
+ root: rootId,
109
+ },
110
+ }),
111
+ );
112
+
113
+ return {
114
+ // Field names must match Mutiro's CardPart protobuf schema (see
115
+ // spec/protobuf/shared/messaging.proto). pi-brain uses stale names
116
+ // (`json_data` / `version`) which the host's strict JSON-to-proto
117
+ // decoder rejects as unknown fields.
118
+ a2ui_json: lines.join("\n"),
119
+ schema_version: "0.8",
120
+ card_id: cardId || `openclaw-card-${Math.random().toString(36).slice(2, 10)}`,
121
+ };
122
+ };
123
+
124
+ export const createMutiroOutbound = (bridge: BridgeClient): MutiroOutbound => {
125
+ const extras = (target: MutiroOutboundTarget) => ({
126
+ conversation_id: target.conversationId,
127
+ reply_to_message_id: target.replyToMessageId,
128
+ });
129
+
130
+ return {
131
+ sendText: async (target, text) => {
132
+ const normalized = normalizeOutputText(text);
133
+ if (!normalized) return { ok: false, reason: "noop" };
134
+ return bridge.request(
135
+ "message.send",
136
+ {
137
+ "@type": TYPE_URLS.bridgeSendMessageCommand,
138
+ conversation_id: target.conversationId,
139
+ reply_to_message_id: target.replyToMessageId,
140
+ text: { text: normalized },
141
+ },
142
+ extras(target),
143
+ );
144
+ },
145
+
146
+ sendVoice: async (target, params) => {
147
+ const normalized = normalizeOutputText(params.speech);
148
+ if (!normalized) return { ok: false, reason: "noop" };
149
+ const voiceName = params.language
150
+ ? applyVoiceLanguage(DEFAULT_VOICE, params.language)
151
+ : DEFAULT_VOICE;
152
+ return bridge.request(
153
+ "message.send_voice",
154
+ {
155
+ "@type": TYPE_URLS.bridgeSendVoiceMessageCommand,
156
+ to_username: params.toUsername.replace(/^@/, ""),
157
+ speech: normalized,
158
+ voice_name: voiceName,
159
+ reply_to_message_id: target.replyToMessageId,
160
+ },
161
+ extras(target),
162
+ );
163
+ },
164
+
165
+ sendCard: async (target, params) => {
166
+ return bridge.request(
167
+ "message.send",
168
+ {
169
+ "@type": TYPE_URLS.bridgeSendMessageCommand,
170
+ conversation_id: target.conversationId,
171
+ reply_to_message_id: target.replyToMessageId,
172
+ parts: {
173
+ parts: [
174
+ {
175
+ card: buildCardJson(
176
+ params.components as Array<Record<string, unknown>>,
177
+ params.data,
178
+ params.cardId,
179
+ ),
180
+ },
181
+ ],
182
+ },
183
+ },
184
+ extras(target),
185
+ );
186
+ },
187
+
188
+ // Ship pre-built A2UI JSONL (same format the canvas tool's a2ui_push
189
+ // action emits). Skips the components-to-JSONL conversion entirely, so
190
+ // the agent can reuse its canvas mental model verbatim.
191
+ sendCardJsonl: async (target, params) => {
192
+ const jsonl = (params.jsonl ?? "").trim();
193
+ if (!jsonl) return { ok: false, reason: "empty_jsonl" };
194
+ return bridge.request(
195
+ "message.send",
196
+ {
197
+ "@type": TYPE_URLS.bridgeSendMessageCommand,
198
+ conversation_id: target.conversationId,
199
+ reply_to_message_id: target.replyToMessageId,
200
+ parts: {
201
+ parts: [
202
+ {
203
+ card: {
204
+ a2ui_json: jsonl,
205
+ schema_version: params.version ?? "0.8",
206
+ card_id:
207
+ params.cardId || `openclaw-card-${Math.random().toString(36).slice(2, 10)}`,
208
+ },
209
+ },
210
+ ],
211
+ },
212
+ },
213
+ extras(target),
214
+ );
215
+ },
216
+
217
+ sendFile: async (target, params) => {
218
+ const uploadRes = (await bridge.request(
219
+ "media.upload",
220
+ {
221
+ "@type": TYPE_URLS.bridgeMediaUploadCommand,
222
+ local_path: params.filePath,
223
+ filename: path.basename(params.filePath),
224
+ mime_type: "application/octet-stream",
225
+ },
226
+ extras(target),
227
+ )) as { media?: unknown } | null;
228
+
229
+ if (!uploadRes?.media) {
230
+ throw new Error(`failed to upload media: ${JSON.stringify(uploadRes)}`);
231
+ }
232
+
233
+ return bridge.request(
234
+ "message.send",
235
+ {
236
+ "@type": TYPE_URLS.bridgeSendMessageCommand,
237
+ conversation_id: target.conversationId,
238
+ reply_to_message_id: target.replyToMessageId,
239
+ parts: {
240
+ parts: [
241
+ {
242
+ file: uploadRes.media,
243
+ ...(params.caption ? { metadata: { caption: params.caption } } : {}),
244
+ },
245
+ ],
246
+ },
247
+ },
248
+ extras(target),
249
+ );
250
+ },
251
+
252
+ react: async (params) => {
253
+ return bridge.request(
254
+ "message.react",
255
+ {
256
+ "@type": TYPE_URLS.addReactionRequest,
257
+ message_id: params.messageId,
258
+ emoji: params.emoji,
259
+ },
260
+ { message_id: params.messageId },
261
+ );
262
+ },
263
+
264
+ forward: async (params) => {
265
+ const toUsername = params.toUsername?.trim().replace(/^@/, "");
266
+ const targetConversationId = params.targetConversationId?.trim();
267
+ if (!toUsername && !targetConversationId) {
268
+ throw new Error(
269
+ "forward requires exactly one of { toUsername, targetConversationId }",
270
+ );
271
+ }
272
+ // `destination` is a proto `oneof`; set exactly one of the two fields.
273
+ return bridge.request("message.forward", {
274
+ "@type": TYPE_URLS.forwardMessageRequest,
275
+ message_id: params.messageId,
276
+ ...(toUsername
277
+ ? { to_username: toUsername }
278
+ : { conversation_id: targetConversationId }),
279
+ comment: params.comment || "",
280
+ });
281
+ },
282
+
283
+ recallSearch: async (params) => {
284
+ return bridge.request("recall.search", {
285
+ "@type": TYPE_URLS.recallSearchRequest,
286
+ query: params.query,
287
+ conversation_id: params.conversationId,
288
+ limit: params.maxResults,
289
+ });
290
+ },
291
+
292
+ recallGet: async (params) => {
293
+ return bridge.request("recall.get", {
294
+ "@type": TYPE_URLS.recallGetRequest,
295
+ entry_id: params.entryId,
296
+ conversation_id: params.conversationId,
297
+ });
298
+ },
299
+
300
+ endTurn: (target) => {
301
+ bridge.send(
302
+ "turn.end",
303
+ {
304
+ "@type": TYPE_URLS.bridgeTurnEndCommand,
305
+ status: "completed",
306
+ },
307
+ extras(target),
308
+ );
309
+ },
310
+
311
+ emitSignal: (target, signalType, detailText = "") => {
312
+ if (!target.conversationId) return;
313
+ bridge.send(
314
+ "signal.emit",
315
+ {
316
+ "@type": TYPE_URLS.sendSignalRequest,
317
+ conversation_id: target.conversationId,
318
+ signal_type: signalType,
319
+ detail_text: detailText,
320
+ in_reply_to: target.replyToMessageId,
321
+ },
322
+ extras(target),
323
+ );
324
+ },
325
+ };
326
+ };