@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,433 @@
1
+ // Long-lived bridge session: owns the subprocess, performs the handshake,
2
+ // dispatches inbound envelopes, and keeps a narrow per-conversation cache for
3
+ // `session.snapshot`. Ported from pi-brain's `main()` but restructured so the
4
+ // OpenClaw plugin runtime can start/stop it per configured account.
5
+
6
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
7
+
8
+ import {
9
+ attachEnvelopeReader,
10
+ type BridgeClient,
11
+ type BridgeLogger,
12
+ createBridgeClient,
13
+ createHostProcess,
14
+ } from "./bridge-client.js";
15
+ import {
16
+ buildSyntheticBridgeMessage,
17
+ cloneMessage,
18
+ trimRecentMessages,
19
+ } from "./bridge-messages.js";
20
+ import {
21
+ DEFAULT_OPTIONAL_CAPABILITIES,
22
+ MAX_RECENT_MESSAGES,
23
+ TYPE_URLS,
24
+ type BridgeEnvelope,
25
+ } from "./bridge-protocol.js";
26
+ import { deliverObservedEnvelope, type InboundDeliver } from "./inbound.js";
27
+ import { createMutiroOutbound, type MutiroOutbound } from "./outbound.js";
28
+
29
+ export type LiveToolHint = {
30
+ name: string;
31
+ description?: string;
32
+ metadata?: Record<string, string>;
33
+ };
34
+
35
+ export type LiveSnapshot = {
36
+ systemInstruction?: string;
37
+ recentMessages?: unknown[];
38
+ promptData?: Record<string, string>;
39
+ toolHints?: LiveToolHint[];
40
+ metadata?: Record<string, string>;
41
+ };
42
+
43
+ /**
44
+ * Invoked when the host requests `session.snapshot` for a voice call
45
+ * handoff. Lets the plugin supply the agent's system prompt, real
46
+ * transcript, and tool hints so the live model starts with the same
47
+ * persona the chat agent has. Resolver may return `undefined` fields to
48
+ * fall back to the bridge's cached synthetic state.
49
+ */
50
+ export type LiveSnapshotResolver = (params: {
51
+ conversationId: string;
52
+ accountId: string;
53
+ callId?: string;
54
+ username?: string;
55
+ }) => Promise<LiveSnapshot | null | undefined>;
56
+
57
+ /**
58
+ * Invoked when the host sends a `task.request`. The brain should run the
59
+ * delegated prompt against OpenClaw's agent and return the accumulated
60
+ * reply text, which the bridge ships back to the host as the
61
+ * `ChatBridgeTaskResult.text`. Implementations should honor the
62
+ * `timeoutMs` window when provided — returning whatever text has
63
+ * accumulated so far is preferable to throwing.
64
+ */
65
+ export type TaskRequestResolver = (params: {
66
+ conversationId: string;
67
+ accountId: string;
68
+ username?: string;
69
+ prompt: string;
70
+ promptData?: Record<string, string>;
71
+ metadata?: Record<string, string>;
72
+ timeoutMs?: number;
73
+ requestId?: string;
74
+ }) => Promise<string>;
75
+
76
+ export type BridgeSessionOptions = {
77
+ accountId: string;
78
+ agentDir: string;
79
+ clientName?: string;
80
+ clientVersion?: string;
81
+ requestedOptionalCapabilities?: string[];
82
+ env?: NodeJS.ProcessEnv;
83
+ logger?: BridgeLogger;
84
+ deliver: InboundDeliver;
85
+ resolveLiveSnapshot?: LiveSnapshotResolver;
86
+ resolveTaskRequest?: TaskRequestResolver;
87
+ onHostExit?: (code: number | null) => void;
88
+ };
89
+
90
+ type ConversationState = {
91
+ recentMessages: unknown[];
92
+ };
93
+
94
+ const consoleLogger: BridgeLogger = {
95
+ info: (msg) => console.log(`[openclaw-mutiro] ${msg}`),
96
+ warn: (msg) => console.warn(`[openclaw-mutiro] ${msg}`),
97
+ error: (msg) => console.error(`[openclaw-mutiro] ${msg}`),
98
+ };
99
+
100
+ export type BridgeSession = {
101
+ accountId: string;
102
+ host: ChildProcessWithoutNullStreams;
103
+ bridge: BridgeClient;
104
+ outbound: MutiroOutbound;
105
+ getAgentUsername: () => string;
106
+ shutdown: () => Promise<void>;
107
+ };
108
+
109
+ export const startBridgeSession = async (
110
+ options: BridgeSessionOptions,
111
+ ): Promise<BridgeSession> => {
112
+ const logger = options.logger ?? consoleLogger;
113
+ const host = createHostProcess({
114
+ agentDir: options.agentDir,
115
+ env: options.env,
116
+ logger,
117
+ onExit: options.onHostExit,
118
+ });
119
+
120
+ const bridge = createBridgeClient(host);
121
+ const outbound = createMutiroOutbound(bridge);
122
+ const conversations = new Map<string, ConversationState>();
123
+ let agentUsername = "";
124
+
125
+ const getConversation = (conversationId: string) => {
126
+ const existing = conversations.get(conversationId);
127
+ if (existing) return existing;
128
+ const state: ConversationState = { recentMessages: [] };
129
+ conversations.set(conversationId, state);
130
+ return state;
131
+ };
132
+
133
+ const appendRecent = (conversationId: string, message: unknown) => {
134
+ if (!message || typeof message !== "object") return;
135
+ const state = getConversation(conversationId);
136
+ state.recentMessages.push(cloneMessage(message));
137
+ state.recentMessages = trimRecentMessages(state.recentMessages, MAX_RECENT_MESSAGES);
138
+ };
139
+
140
+ const initializeBridge = async () => {
141
+ // Standalone bridge mode mirrors the documented handshake:
142
+ // ready → session.initialize → subscription.set → message.observed.
143
+ logger.info("host ready, sending initialization");
144
+ await bridge.request("session.initialize", {
145
+ "@type": TYPE_URLS.bridgeInitializeCommand,
146
+ role: "brain",
147
+ client_name: options.clientName ?? "openclaw-mutiro-bridge",
148
+ client_version: options.clientVersion ?? "1.0.0",
149
+ requested_optional_capabilities:
150
+ options.requestedOptionalCapabilities ?? DEFAULT_OPTIONAL_CAPABILITIES,
151
+ });
152
+ logger.info("subscribing to event stream");
153
+ await bridge.request("subscription.set", {
154
+ "@type": TYPE_URLS.bridgeSubscriptionSetCommand,
155
+ all: true,
156
+ conversation_ids: [],
157
+ });
158
+ logger.info("handshake complete, listening for messages");
159
+ };
160
+
161
+ const handleObservedMessage = async (envelope: BridgeEnvelope) => {
162
+ if (envelope.type === "message.observed") {
163
+ // Ack delivery immediately so the host knows we accepted the turn, even
164
+ // though the actual visible reply will happen later via message.send.
165
+ bridge.ack(envelope.request_id!, TYPE_URLS.bridgeMessageObservedResult);
166
+ }
167
+
168
+ const turn = await deliverObservedEnvelope(envelope, {
169
+ accountId: options.accountId,
170
+ agentUsername,
171
+ deliver: options.deliver,
172
+ });
173
+
174
+ if (!turn) {
175
+ if (envelope.conversation_id && envelope.message_id) {
176
+ outbound.endTurn({
177
+ conversationId: envelope.conversation_id,
178
+ replyToMessageId: envelope.message_id,
179
+ });
180
+ }
181
+ return;
182
+ }
183
+
184
+ appendRecent(turn.conversationId, (envelope.payload as { message?: unknown })?.message);
185
+ };
186
+
187
+ const handleTaskRequest = async (envelope: BridgeEnvelope) => {
188
+ // ChatBridgeTaskRequest fields (see spec/protobuf/shared/chat_bridge.proto):
189
+ // conversation_id, username, prompt, prompt_data, metadata, timeout_ms.
190
+ // The response MUST carry the agent's reply text inside
191
+ // ChatBridgeTaskResult; unlike message.observed there is no secondary
192
+ // message.send path — the host waits for this command_result.
193
+ const payload = (envelope.payload ?? {}) as {
194
+ conversation_id?: string;
195
+ username?: string;
196
+ prompt?: string;
197
+ prompt_data?: Record<string, string>;
198
+ metadata?: Record<string, string>;
199
+ timeout_ms?: number | string;
200
+ };
201
+ const conversationId =
202
+ payload.conversation_id || envelope.conversation_id || "task-queue";
203
+ const prompt = (payload.prompt ?? "").trim();
204
+
205
+ const timeoutMs =
206
+ typeof payload.timeout_ms === "number"
207
+ ? payload.timeout_ms
208
+ : typeof payload.timeout_ms === "string"
209
+ ? Number.parseInt(payload.timeout_ms, 10) || undefined
210
+ : undefined;
211
+
212
+ let resultText = "";
213
+ if (!prompt) {
214
+ logger.warn("task.request arrived without a prompt; returning empty result");
215
+ } else if (!options.resolveTaskRequest) {
216
+ logger.warn("task.request received but no resolveTaskRequest is configured");
217
+ } else {
218
+ try {
219
+ resultText = await options.resolveTaskRequest({
220
+ conversationId,
221
+ accountId: options.accountId,
222
+ username: payload.username,
223
+ prompt,
224
+ promptData: payload.prompt_data,
225
+ metadata: payload.metadata,
226
+ timeoutMs,
227
+ requestId: envelope.request_id,
228
+ });
229
+ } catch (err) {
230
+ logger.error(
231
+ `task.request resolver failed: ${err instanceof Error ? err.message : String(err)}`,
232
+ );
233
+ resultText = "";
234
+ }
235
+ }
236
+
237
+ bridge.send(
238
+ "command_result",
239
+ {
240
+ "@type": TYPE_URLS.bridgeCommandResult,
241
+ ok: true,
242
+ response: {
243
+ "@type": TYPE_URLS.bridgeTaskResult,
244
+ text: resultText,
245
+ },
246
+ },
247
+ {
248
+ request_id: envelope.request_id,
249
+ conversation_id: conversationId,
250
+ },
251
+ );
252
+ };
253
+
254
+ const handleSessionSnapshot = async (envelope: BridgeEnvelope) => {
255
+ const payload = (envelope.payload ?? {}) as {
256
+ conversation_id?: string;
257
+ username?: string;
258
+ call_id?: string;
259
+ };
260
+ const conversationId = payload.conversation_id || envelope.conversation_id;
261
+ logger.info(
262
+ `session.snapshot requested: conversation_id=${conversationId ?? ""} call_id=${payload.call_id ?? ""} username=${payload.username ?? ""}`,
263
+ );
264
+ if (!conversationId) {
265
+ bridge.sendError(envelope.request_id, "invalid_request", "session.snapshot conversation_id is required");
266
+ return;
267
+ }
268
+
269
+ const cached = conversations.get(conversationId);
270
+
271
+ // Ask the plugin runtime to build a rich snapshot. The resolver may
272
+ // return the real agent system prompt, the real session transcript, and
273
+ // channel-owned tool hints so the live voice model starts with the same
274
+ // persona the chat brain has. Any undefined field falls back to cached
275
+ // synthetic state so an offline/missing resolver degrades gracefully.
276
+ let snapshot: LiveSnapshot | null | undefined;
277
+ if (options.resolveLiveSnapshot) {
278
+ try {
279
+ snapshot = await options.resolveLiveSnapshot({
280
+ conversationId,
281
+ accountId: options.accountId,
282
+ callId: payload.call_id,
283
+ username: payload.username,
284
+ });
285
+ } catch (err) {
286
+ logger.warn(
287
+ `session.snapshot resolver failed: ${err instanceof Error ? err.message : String(err)}`,
288
+ );
289
+ snapshot = null;
290
+ }
291
+ }
292
+
293
+ const response: Record<string, unknown> = {
294
+ "@type": TYPE_URLS.bridgeSessionSnapshotResult,
295
+ recent_messages: snapshot?.recentMessages ?? cached?.recentMessages ?? [],
296
+ metadata: {
297
+ conversation_id: conversationId,
298
+ ...(snapshot?.metadata ?? {}),
299
+ },
300
+ };
301
+ if (snapshot?.systemInstruction && snapshot.systemInstruction.trim()) {
302
+ response.system_instruction = snapshot.systemInstruction;
303
+ }
304
+ if (snapshot?.promptData && Object.keys(snapshot.promptData).length > 0) {
305
+ response.prompt_data = snapshot.promptData;
306
+ }
307
+ if (snapshot?.toolHints && snapshot.toolHints.length > 0) {
308
+ response.tool_hints = snapshot.toolHints.map((hint) => ({
309
+ name: hint.name,
310
+ description: hint.description ?? "",
311
+ metadata: hint.metadata ?? {},
312
+ }));
313
+ }
314
+
315
+ bridge.send(
316
+ "command_result",
317
+ {
318
+ "@type": TYPE_URLS.bridgeCommandResult,
319
+ ok: true,
320
+ response,
321
+ },
322
+ {
323
+ request_id: envelope.request_id,
324
+ conversation_id: conversationId,
325
+ },
326
+ );
327
+ };
328
+
329
+ const handleSessionObserved = async (envelope: BridgeEnvelope) => {
330
+ const payload = (envelope.payload ?? {}) as {
331
+ conversation_id?: string;
332
+ text?: string;
333
+ source?: string;
334
+ };
335
+ const conversationId = payload.conversation_id || envelope.conversation_id;
336
+ if (!conversationId) {
337
+ bridge.sendError(envelope.request_id, "invalid_request", "session.observed conversation_id is required");
338
+ return;
339
+ }
340
+
341
+ const observedText = (payload.text || "").trim();
342
+ if (observedText) {
343
+ appendRecent(
344
+ conversationId,
345
+ buildSyntheticBridgeMessage({
346
+ conversationId,
347
+ senderUsername: "system",
348
+ text: observedText,
349
+ metadata: { source: (payload.source || "").trim() },
350
+ }),
351
+ );
352
+ }
353
+
354
+ bridge.ack(envelope.request_id!, TYPE_URLS.bridgeSessionObservedResult);
355
+ };
356
+
357
+ attachEnvelopeReader(
358
+ host,
359
+ async (envelope) => {
360
+ switch (envelope.type) {
361
+ case "ready": {
362
+ const payload = (envelope.payload ?? {}) as { agent_username?: string };
363
+ agentUsername = payload.agent_username || agentUsername;
364
+ try {
365
+ await initializeBridge();
366
+ } catch (err) {
367
+ logger.error(
368
+ `handshake failed: ${err instanceof Error ? err.message : String(err)}`,
369
+ );
370
+ }
371
+ return;
372
+ }
373
+ case "command_result":
374
+ bridge.resolveResponse(envelope.request_id, envelope.payload);
375
+ return;
376
+ case "error":
377
+ if (!bridge.rejectResponse(envelope.request_id, envelope.error)) {
378
+ logger.error(`host error: ${JSON.stringify(envelope.error)}`);
379
+ }
380
+ return;
381
+ case "message.observed":
382
+ case "event.message":
383
+ await handleObservedMessage(envelope);
384
+ return;
385
+ case "task.request":
386
+ await handleTaskRequest(envelope);
387
+ return;
388
+ case "session.snapshot":
389
+ await handleSessionSnapshot(envelope);
390
+ return;
391
+ case "session.observed":
392
+ await handleSessionObserved(envelope);
393
+ return;
394
+ default:
395
+ if (envelope.request_id) {
396
+ bridge.sendError(
397
+ envelope.request_id,
398
+ "unsupported_envelope",
399
+ `unsupported envelope type ${JSON.stringify(envelope.type)}`,
400
+ {
401
+ conversation_id: envelope.conversation_id,
402
+ message_id: envelope.message_id,
403
+ reply_to_message_id: envelope.reply_to_message_id,
404
+ },
405
+ );
406
+ }
407
+ }
408
+ },
409
+ logger,
410
+ );
411
+
412
+ const shutdown = async () => {
413
+ try {
414
+ bridge.send("host.shutdown", { "@type": "type.googleapis.com/mutiro.chatbridge.ChatBridgeShutdownCommand" });
415
+ } catch {
416
+ // best-effort
417
+ }
418
+ host.kill("SIGTERM");
419
+ await new Promise<void>((resolve) => {
420
+ if (host.exitCode !== null) resolve();
421
+ else host.once("exit", () => resolve());
422
+ });
423
+ };
424
+
425
+ return {
426
+ accountId: options.accountId,
427
+ host,
428
+ bridge,
429
+ outbound,
430
+ getAgentUsername: () => agentUsername,
431
+ shutdown,
432
+ };
433
+ };