@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.
@@ -0,0 +1,236 @@
1
+ // NDJSON envelope codec + subprocess manager for the Mutiro chatbridge.
2
+ // Ported from pi-brain's `createHostProcess` and `createBridgeClient`, kept
3
+ // transport-shaped so the rest of the plugin can treat the bridge as a
4
+ // request/response channel regardless of which brain is on the other side.
5
+
6
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
7
+ import * as readline from "node:readline";
8
+
9
+ import {
10
+ type BridgeEnvelope,
11
+ type BridgeExtras,
12
+ type PendingRequest,
13
+ PROTOCOL_VERSION,
14
+ TYPE_URLS,
15
+ generateId,
16
+ } from "./bridge-protocol.js";
17
+
18
+ export type BridgeLogger = {
19
+ info: (msg: string) => void;
20
+ warn: (msg: string) => void;
21
+ error: (msg: string) => void;
22
+ };
23
+
24
+ const defaultLogger: BridgeLogger = {
25
+ info: (msg) => console.log(`[openclaw-mutiro] ${msg}`),
26
+ warn: (msg) => console.warn(`[openclaw-mutiro] ${msg}`),
27
+ error: (msg) => console.error(`[openclaw-mutiro] ${msg}`),
28
+ };
29
+
30
+ export type HostProcessOptions = {
31
+ agentDir: string;
32
+ env?: NodeJS.ProcessEnv;
33
+ logger?: BridgeLogger;
34
+ onExit?: (code: number | null) => void;
35
+ };
36
+
37
+ // In bridge mode the Mutiro host writes slog JSON records to stderr. Parse
38
+ // each line and route it through the OpenClaw channel logger so the output
39
+ // matches the rest of the gateway's log stream instead of leaking the raw
40
+ // Go-side format.
41
+ const HOST_ATTR_DROP = new Set(["time", "level", "msg", "component", "agent_username"]);
42
+
43
+ const formatAttrValue = (value: unknown): string => {
44
+ if (value === null || value === undefined) return "";
45
+ if (typeof value === "string") return value;
46
+ if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
47
+ return String(value);
48
+ }
49
+ try {
50
+ return JSON.stringify(value);
51
+ } catch {
52
+ return String(value);
53
+ }
54
+ };
55
+
56
+ type NormalizedHostLog = { level: "info" | "warn" | "error"; text: string };
57
+
58
+ const normalizeHostLogLine = (raw: string): NormalizedHostLog => {
59
+ const trimmed = raw.trim();
60
+ if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
61
+ try {
62
+ const parsed = JSON.parse(trimmed) as Record<string, unknown>;
63
+ if (parsed && typeof parsed.msg === "string") {
64
+ const rawLevel = typeof parsed.level === "string" ? parsed.level.toLowerCase() : "info";
65
+ const level: NormalizedHostLog["level"] =
66
+ rawLevel === "error"
67
+ ? "error"
68
+ : rawLevel === "warn" || rawLevel === "warning"
69
+ ? "warn"
70
+ : "info";
71
+ const attrs = Object.entries(parsed)
72
+ .filter(([key]) => !HOST_ATTR_DROP.has(key))
73
+ .map(([key, value]) => `${key}=${formatAttrValue(value)}`)
74
+ .filter((entry) => entry.length > `=`.length + 1);
75
+ const detail = attrs.length > 0 ? ` ${attrs.join(" ")}` : "";
76
+ return { level, text: `host: ${parsed.msg}${detail}` };
77
+ }
78
+ } catch {
79
+ // fall through to raw passthrough
80
+ }
81
+ }
82
+ return { level: "info", text: `host: ${trimmed}` };
83
+ };
84
+
85
+ export const createHostProcess = (options: HostProcessOptions) => {
86
+ const logger = options.logger ?? defaultLogger;
87
+ const hostProcess = spawn("mutiro", ["agent", "host", "--mode=bridge"], {
88
+ cwd: options.agentDir,
89
+ env: options.env ?? process.env,
90
+ });
91
+
92
+ const stderrReader = readline.createInterface({
93
+ input: hostProcess.stderr,
94
+ terminal: false,
95
+ });
96
+ stderrReader.on("line", (line) => {
97
+ if (!line.trim()) return;
98
+ const { level, text } = normalizeHostLogLine(line);
99
+ if (level === "error") logger.error(text);
100
+ else if (level === "warn") logger.warn(text);
101
+ else logger.info(text);
102
+ });
103
+
104
+ hostProcess.on("exit", (code) => {
105
+ stderrReader.close();
106
+ logger.info(`mutiro host exited with code ${code}`);
107
+ options.onExit?.(code ?? null);
108
+ });
109
+
110
+ return hostProcess;
111
+ };
112
+
113
+ export type BridgeClient = {
114
+ send: (type: string, payload: unknown, extras?: BridgeExtras) => void;
115
+ request: <T = unknown>(type: string, payload: unknown, extras?: BridgeExtras) => Promise<T>;
116
+ ack: (requestId: string, payloadType: string) => void;
117
+ resolveResponse: (requestId: string | undefined, payload: unknown) => boolean;
118
+ rejectResponse: (requestId: string | undefined, error: unknown) => boolean;
119
+ sendError: (
120
+ requestId: string | undefined,
121
+ code: string,
122
+ message: string,
123
+ extras?: BridgeExtras,
124
+ ) => void;
125
+ };
126
+
127
+ export const createBridgeClient = (
128
+ hostProcess: ChildProcessWithoutNullStreams,
129
+ ): BridgeClient => {
130
+ // Bridge requests are ordinary NDJSON envelopes with request/response
131
+ // correlation on request_id. Visible chat replies are *not* the response to
132
+ // message.observed; they are separate outbound bridge requests.
133
+ const pendingRequests = new Map<string, PendingRequest>();
134
+
135
+ const send = (type: string, payload: unknown, extras: BridgeExtras = {}) => {
136
+ const envelope = {
137
+ protocol_version: PROTOCOL_VERSION,
138
+ type,
139
+ request_id: extras.request_id || generateId(),
140
+ payload,
141
+ ...extras,
142
+ };
143
+ hostProcess.stdin.write(`${JSON.stringify(envelope)}\n`);
144
+ };
145
+
146
+ const request = <T = unknown>(type: string, payload: unknown, extras: BridgeExtras = {}) =>
147
+ new Promise<T>((resolve, reject) => {
148
+ const requestId = generateId();
149
+ pendingRequests.set(requestId, {
150
+ resolve: resolve as (value: unknown) => void,
151
+ reject,
152
+ });
153
+ send(type, payload, { ...extras, request_id: requestId });
154
+ });
155
+
156
+ const ack = (requestId: string, payloadType: string) => {
157
+ // Acknowledge host-owned request delivery. This is separate from sending a
158
+ // user-visible message back into Mutiro.
159
+ send(
160
+ "command_result",
161
+ {
162
+ "@type": TYPE_URLS.bridgeCommandResult,
163
+ ok: true,
164
+ response: { "@type": payloadType },
165
+ },
166
+ { request_id: requestId },
167
+ );
168
+ };
169
+
170
+ const resolveResponse = (requestId: string | undefined, payload: unknown) => {
171
+ if (!requestId || !pendingRequests.has(requestId)) return false;
172
+ const pending = pendingRequests.get(requestId)!;
173
+ const resolved =
174
+ payload && typeof payload === "object" && "response" in (payload as Record<string, unknown>)
175
+ ? (payload as { response: unknown }).response
176
+ : payload;
177
+ pending.resolve(resolved);
178
+ pendingRequests.delete(requestId);
179
+ return true;
180
+ };
181
+
182
+ const rejectResponse = (requestId: string | undefined, error: unknown) => {
183
+ if (!requestId || !pendingRequests.has(requestId)) return false;
184
+ pendingRequests.get(requestId)!.reject(error);
185
+ pendingRequests.delete(requestId);
186
+ return true;
187
+ };
188
+
189
+ const sendError = (
190
+ requestId: string | undefined,
191
+ code: string,
192
+ message: string,
193
+ extras: BridgeExtras = {},
194
+ ) => {
195
+ if (!requestId) return;
196
+ const envelope = {
197
+ protocol_version: PROTOCOL_VERSION,
198
+ type: "error",
199
+ request_id: requestId,
200
+ error: { code, message },
201
+ ...extras,
202
+ };
203
+ hostProcess.stdin.write(`${JSON.stringify(envelope)}\n`);
204
+ };
205
+
206
+ return {
207
+ ack,
208
+ rejectResponse,
209
+ request,
210
+ resolveResponse,
211
+ send,
212
+ sendError,
213
+ };
214
+ };
215
+
216
+ export type EnvelopeHandler = (envelope: BridgeEnvelope) => void | Promise<void>;
217
+
218
+ export const attachEnvelopeReader = (
219
+ hostProcess: ChildProcessWithoutNullStreams,
220
+ handler: EnvelopeHandler,
221
+ logger: BridgeLogger = defaultLogger,
222
+ ) => {
223
+ const rl = readline.createInterface({ input: hostProcess.stdout, terminal: false });
224
+
225
+ rl.on("line", async (line) => {
226
+ if (!line.trim()) return;
227
+ try {
228
+ const envelope = JSON.parse(line) as BridgeEnvelope;
229
+ await handler(envelope);
230
+ } catch (err) {
231
+ logger.error(`error processing bridge line: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ });
234
+
235
+ return rl;
236
+ };
@@ -0,0 +1,307 @@
1
+ // Message normalization helpers ported from pi-brain/mutiro-pi-bridge.ts.
2
+ // The host delivers `envelope.payload.message` as a pre-normalized bag of parts;
3
+ // these helpers turn that into plain text for the brain and into structured
4
+ // ObservedTurn records for downstream dispatch.
5
+
6
+ import type { ObservedTurn } from "./bridge-protocol.js";
7
+ import { generateId } from "./bridge-protocol.js";
8
+
9
+ const shortMessageId = (value?: string) => {
10
+ const id = (value || "").trim();
11
+ return id.length <= 8 ? id : id.slice(-8);
12
+ };
13
+
14
+ /**
15
+ * Converts a normalized bridge message into plain text for the LLM.
16
+ *
17
+ * The host delivers messages as `envelope.payload.message` with the following shape:
18
+ *
19
+ * { text?: string, parts?: ChatBridgeMessagePart[], reply_to_message_id?: string, ... }
20
+ *
21
+ * `parts` is an array of flat objects, each carrying a `type` string discriminator.
22
+ * The host digests the raw wire format into this clean shape before delivery, so
23
+ * brain implementations only need to care about the fields documented below.
24
+ *
25
+ * Attachment bytes for `image` and `file` parts are downloaded by the host into
26
+ * `{agent_workspace}/Downloads/` before delivery. The local paths are conveyed
27
+ * separately via `envelope.payload.attachment_context`; see `buildObservedTurn`.
28
+ *
29
+ * @see https://docs.mutiro.com/chatbridge-protocol
30
+ */
31
+ const REACTION_QUOTE_MAX_CHARS = 160;
32
+
33
+ const truncateReactionQuote = (raw: string): string => {
34
+ const collapsed = raw.replace(/\s+/g, " ").trim();
35
+ if (!collapsed) return "";
36
+ if (collapsed.length <= REACTION_QUOTE_MAX_CHARS) return collapsed;
37
+ return `${collapsed.slice(0, REACTION_QUOTE_MAX_CHARS - 1).trimEnd()}…`;
38
+ };
39
+
40
+ /**
41
+ * Optional context carried on the envelope that lets extractors render
42
+ * richer text. Today: `replyToMessagePreview` comes from
43
+ * `ChatBridgeMessageObserved.reply_to_message_preview` — a host-resolved
44
+ * quote of the message referenced by `reply_to_message_id`. Used to turn
45
+ * bare `[reacted 👍 to #abc12345]` placeholders into readable events the
46
+ * model can reason about.
47
+ */
48
+ export type BridgeMessageExtractionContext = {
49
+ replyToMessagePreview?: string;
50
+ };
51
+
52
+ export const extractBridgeMessageText = (
53
+ message?: {
54
+ text?: string;
55
+ parts?: Array<Record<string, unknown>>;
56
+ reply_to_message_id?: string;
57
+ },
58
+ context?: BridgeMessageExtractionContext,
59
+ ) => {
60
+ if (!message) return "";
61
+ const replyPreview = (context?.replyToMessagePreview ?? "").trim();
62
+
63
+ const parts: string[] = [];
64
+ const push = (value?: string) => {
65
+ const trimmed = (value || "").trim();
66
+ if (trimmed) parts.push(trimmed);
67
+ };
68
+
69
+ push(message.text);
70
+
71
+ for (const part of Array.isArray(message.parts) ? message.parts : []) {
72
+ if (!part || typeof part !== "object") continue;
73
+
74
+ const partType = (part as { type?: string }).type;
75
+ switch (partType) {
76
+ case "text":
77
+ push((part as { text?: string }).text);
78
+ break;
79
+ case "audio":
80
+ push((part as { transcript?: string }).transcript);
81
+ break;
82
+ case "card": {
83
+ const cardId = (part as { card_id?: string }).card_id;
84
+ push(cardId ? `[Interactive card: ${cardId}]` : "[Interactive card]");
85
+ break;
86
+ }
87
+ case "card_action": {
88
+ const p = part as { card_id?: string; action_id?: string; data_json?: string };
89
+ push(
90
+ `[Card interaction: card=${p.card_id || ""} action=${p.action_id || ""} data=${p.data_json || ""}]`,
91
+ );
92
+ break;
93
+ }
94
+ case "contact": {
95
+ const meta = ((part as { metadata?: Record<string, string> }).metadata || {}) as Record<
96
+ string,
97
+ string
98
+ >;
99
+ const username = (meta.contact_username || "").trim();
100
+ if (!username) break;
101
+ const displayName = (meta.contact_display_name || "").trim();
102
+ const role = (meta.contact_member_type || "").trim() === "agent" ? "agent" : "user";
103
+ push(`[Shared contact: ${displayName || username} (@${username}, ${role})]`);
104
+ break;
105
+ }
106
+ case "reaction": {
107
+ const p = part as { reaction?: string; reaction_operation?: string };
108
+ const emoji = (p.reaction || "").trim();
109
+ if (!emoji) break;
110
+ const removed = (p.reaction_operation || "").trim().toLowerCase() === "removed";
111
+ const quote = truncateReactionQuote(replyPreview);
112
+ if (quote) {
113
+ push(
114
+ removed
115
+ ? `[reaction ${emoji} removed from message: "${quote}"]`
116
+ : `[reaction ${emoji} received on message: "${quote}"]`,
117
+ );
118
+ } else {
119
+ const target = shortMessageId(message.reply_to_message_id);
120
+ if (removed) {
121
+ push(
122
+ target
123
+ ? `[removed reaction ${emoji} from #${target}]`
124
+ : `[removed reaction ${emoji}]`,
125
+ );
126
+ } else {
127
+ push(target ? `[reacted ${emoji} to #${target}]` : `[reacted ${emoji}]`);
128
+ }
129
+ }
130
+ break;
131
+ }
132
+ case "live_call": {
133
+ const p = part as {
134
+ summary_text?: string;
135
+ action_items?: string[];
136
+ follow_ups?: string[];
137
+ call_id?: string;
138
+ end_reason?: string;
139
+ };
140
+ const summary = (p.summary_text || "").trim();
141
+ const actionItems = Array.isArray(p.action_items)
142
+ ? p.action_items.map((item) => item.trim()).filter(Boolean)
143
+ : [];
144
+ const followUps = Array.isArray(p.follow_ups)
145
+ ? p.follow_ups.map((item) => item.trim()).filter(Boolean)
146
+ : [];
147
+ // Always emit at least the header — the part existing signals the
148
+ // call ended, even when summarization produced no text. Skipping
149
+ // here produces an empty turn text, and downstream that becomes a
150
+ // "no extractable content" drop. Symmetric to the host-side fix in
151
+ // chatbridge/normalize.go.
152
+ const lines = [
153
+ `[Voice call summary (call_id=${(p.call_id || "").trim()}, end_reason=${(p.end_reason || "").trim()})]`,
154
+ ];
155
+ if (summary) lines.push(summary);
156
+ if (actionItems.length > 0)
157
+ lines.push(`Action items:\n${actionItems.map((item) => `- ${item}`).join("\n")}`);
158
+ if (followUps.length > 0)
159
+ lines.push(`Follow-ups:\n${followUps.map((item) => `- ${item}`).join("\n")}`);
160
+ push(lines.join("\n"));
161
+ break;
162
+ }
163
+ case "image": {
164
+ const caption = (
165
+ ((part as { metadata?: Record<string, string> }).metadata || {}).caption || ""
166
+ ).trim();
167
+ push(caption ? `[Image attachment: ${caption}]` : "[Image attachment]");
168
+ break;
169
+ }
170
+ case "file": {
171
+ const filename = ((part as { filename?: string }).filename || "").trim();
172
+ const caption = (
173
+ ((part as { metadata?: Record<string, string> }).metadata || {}).caption || ""
174
+ ).trim();
175
+ push(
176
+ caption
177
+ ? `[File attachment: ${filename || "attachment"} — ${caption}]`
178
+ : `[File attachment: ${filename || "attachment"}]`,
179
+ );
180
+ break;
181
+ }
182
+ }
183
+ }
184
+
185
+ return parts.join(" ").trim();
186
+ };
187
+
188
+ export const normalizeOutputText = (value: string) => {
189
+ const trimmed = (value || "").trim();
190
+ const lowered = trimmed.toLowerCase();
191
+ if (!trimmed) return "";
192
+ if (lowered === "noop" || lowered === "noop.") return "";
193
+ return trimmed;
194
+ };
195
+
196
+ const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value));
197
+
198
+ export const trimRecentMessages = <T>(messages: T[], max: number) =>
199
+ messages.length > max ? messages.slice(-max) : messages;
200
+
201
+ export const buildSyntheticBridgeMessage = (params: {
202
+ conversationId: string;
203
+ replyToMessageId?: string;
204
+ senderUsername: string;
205
+ text: string;
206
+ metadata?: Record<string, string>;
207
+ }) => ({
208
+ id: `openclaw-${generateId()}`,
209
+ conversation_id: params.conversationId,
210
+ reply_to_message_id: params.replyToMessageId || "",
211
+ from: {
212
+ username: params.senderUsername,
213
+ },
214
+ text: params.text,
215
+ metadata: params.metadata || {},
216
+ });
217
+
218
+ export const cloneMessage = <T>(message: T): T => cloneJson(message);
219
+
220
+ /**
221
+ * Assembles a promptable ObservedTurn from an inbound host envelope.
222
+ *
223
+ * `extractBridgeMessageText(envelope.payload.message)` converts each message
224
+ * part (text, audio transcript, card placeholder, etc.) into inline plain text
225
+ * for the body.
226
+ *
227
+ * We intentionally do NOT glue `envelope.payload.attachment_context` onto the
228
+ * body. The host-authored description can carry stale or wrong metadata (for
229
+ * example "0x0 pixels" when its image probe fails) and, once real bytes are
230
+ * staged via MediaPaths, mixing it into Body confuses the model more than it
231
+ * helps. Callers that need the raw description can read it separately via
232
+ * `InboundMessage.attachmentContext`.
233
+ *
234
+ * Returns null if conversation/message ids are missing, or when both the text
235
+ * body and any inline image attachments are empty.
236
+ */
237
+ export const buildObservedTurn = (envelope: {
238
+ conversation_id?: string;
239
+ message_id?: string;
240
+ reply_to_message_id?: string;
241
+ payload?: {
242
+ message?: {
243
+ conversation_id?: string;
244
+ id?: string;
245
+ reply_to_message_id?: string;
246
+ from?: { username?: string };
247
+ text?: string;
248
+ parts?: Array<Record<string, unknown>>;
249
+ };
250
+ reply_to_message_id?: string;
251
+ attachment_context?: string;
252
+ reply_to_message_preview?: string;
253
+ images?: unknown[];
254
+ };
255
+ }): ObservedTurn | null => {
256
+ const conversationId = envelope.conversation_id || envelope.payload?.message?.conversation_id;
257
+ const messageId = envelope.message_id || envelope.payload?.message?.id;
258
+ const text = extractBridgeMessageText(envelope.payload?.message, {
259
+ replyToMessagePreview: envelope.payload?.reply_to_message_preview,
260
+ });
261
+ const hasAttachments =
262
+ Array.isArray(envelope.payload?.images) && (envelope.payload.images?.length ?? 0) > 0;
263
+
264
+ if (!conversationId || !messageId || (!text && !hasAttachments)) {
265
+ return null;
266
+ }
267
+
268
+ return {
269
+ conversationId,
270
+ messageId,
271
+ replyToMessageId:
272
+ envelope.reply_to_message_id ||
273
+ envelope.payload?.reply_to_message_id ||
274
+ envelope.payload?.message?.reply_to_message_id,
275
+ senderUsername: envelope.payload?.message?.from?.username || "unknown",
276
+ text,
277
+ };
278
+ };
279
+
280
+ export const isSelfEventMessage = (
281
+ envelope: { payload?: { message?: { from?: { username?: string } } } },
282
+ agentUsername: string,
283
+ ) => {
284
+ const senderUsername = envelope.payload?.message?.from?.username;
285
+ const selfUsername = (agentUsername || "").trim();
286
+ return !senderUsername || (!!selfUsername && senderUsername === selfUsername);
287
+ };
288
+
289
+ export const applyVoiceLanguage = (voiceName: string, language: string) => {
290
+ const trimmedVoice = voiceName.trim();
291
+ const trimmedLanguage = language.trim();
292
+ if (!trimmedVoice || !trimmedLanguage) {
293
+ return trimmedVoice;
294
+ }
295
+
296
+ const languageParts = trimmedLanguage.split("-");
297
+ if (languageParts.length < 2) {
298
+ return trimmedVoice;
299
+ }
300
+
301
+ const voiceParts = trimmedVoice.split("-");
302
+ if (voiceParts.length < 4) {
303
+ return trimmedVoice;
304
+ }
305
+
306
+ return `${languageParts[0]}-${languageParts[1]}-${voiceParts.slice(2).join("-")}`;
307
+ };
@@ -0,0 +1,77 @@
1
+ // NDJSON protocol constants used by the Mutiro chatbridge envelope.
2
+ // Ported verbatim from pi-brain/mutiro-pi-bridge.ts so that behavior matches
3
+ // the reference adapter envelope-for-envelope.
4
+
5
+ export const PROTOCOL_VERSION = "mutiro.agent.bridge.v1";
6
+
7
+ export const TYPE_URLS = {
8
+ addReactionRequest: "type.googleapis.com/mutiro.messaging.AddReactionRequest",
9
+ bridgeCommandResult: "type.googleapis.com/mutiro.chatbridge.ChatBridgeCommandResult",
10
+ bridgeInitializeCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeInitializeCommand",
11
+ bridgeMediaUploadCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeMediaUploadCommand",
12
+ bridgeSendMessageCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeSendMessageCommand",
13
+ bridgeSendVoiceMessageCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeSendVoiceMessageCommand",
14
+ bridgeMessageObservedResult: "type.googleapis.com/mutiro.chatbridge.ChatBridgeMessageObservedResult",
15
+ bridgeSessionObservedResult: "type.googleapis.com/mutiro.chatbridge.ChatBridgeSessionObservedResult",
16
+ bridgeSessionSnapshotResult: "type.googleapis.com/mutiro.chatbridge.ChatBridgeSessionSnapshotResult",
17
+ bridgeSubscriptionSetCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeSubscriptionSetCommand",
18
+ bridgeTaskResult: "type.googleapis.com/mutiro.chatbridge.ChatBridgeTaskResult",
19
+ bridgeTurnEndCommand: "type.googleapis.com/mutiro.chatbridge.ChatBridgeTurnEndCommand",
20
+ forwardMessageRequest: "type.googleapis.com/mutiro.messaging.ForwardMessageRequest",
21
+ recallGetRequest: "type.googleapis.com/mutiro.recall.RecallGetRequest",
22
+ recallSearchRequest: "type.googleapis.com/mutiro.recall.RecallSearchRequest",
23
+ sendSignalRequest: "type.googleapis.com/mutiro.signal.SendSignalRequest",
24
+ } as const;
25
+
26
+ export const DEFAULT_OPTIONAL_CAPABILITIES = [
27
+ "message.send_voice",
28
+ "signal.emit",
29
+ "recall.search",
30
+ "recall.get",
31
+ "media.upload",
32
+ // Advertising session.snapshot opts us into the live-call handoff: the
33
+ // host only sends ChatBridgeSessionSnapshotRequest to brains that
34
+ // declare support. Without this, our resolver never fires and the live
35
+ // voice model starts the call with no persona, no transcript, no tools.
36
+ "session.snapshot",
37
+ // Advertising task.request lets the host delegate one-shot prompts to
38
+ // the brain (e.g. scheduled reminders, background lookups). Our
39
+ // resolver runs the prompt against OpenClaw's agent and returns the
40
+ // accumulated reply text in the ChatBridgeTaskResult envelope.
41
+ "task.request",
42
+ ];
43
+
44
+ export const MAX_RECENT_MESSAGES = 30;
45
+
46
+ export type BridgeExtras = {
47
+ request_id?: string;
48
+ conversation_id?: string;
49
+ message_id?: string;
50
+ reply_to_message_id?: string;
51
+ };
52
+
53
+ export type ObservedTurn = {
54
+ conversationId: string;
55
+ messageId: string;
56
+ replyToMessageId?: string;
57
+ senderUsername: string;
58
+ text: string;
59
+ };
60
+
61
+ export type PendingRequest = {
62
+ resolve: (value: unknown) => void;
63
+ reject: (error: unknown) => void;
64
+ };
65
+
66
+ export type BridgeEnvelope = {
67
+ protocol_version: string;
68
+ type: string;
69
+ request_id?: string;
70
+ conversation_id?: string;
71
+ message_id?: string;
72
+ reply_to_message_id?: string;
73
+ payload?: Record<string, unknown>;
74
+ error?: { code?: string; message?: string };
75
+ };
76
+
77
+ export const generateId = () => Math.random().toString(36).substring(2, 15);