@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/CHANGELOG.md +50 -0
- package/LICENSE +15 -0
- package/README.md +266 -0
- package/docs/guides/manage-allowlist.md +184 -0
- package/docs/guides/use-openclaw-as-brain.md +401 -0
- package/index.ts +21 -0
- package/openclaw.plugin.json +47 -0
- package/package.json +79 -0
- package/src/actions.ts +53 -0
- package/src/agent-tools.ts +640 -0
- package/src/bridge-client.ts +236 -0
- package/src/bridge-messages.ts +307 -0
- package/src/bridge-protocol.ts +77 -0
- package/src/bridge-session.ts +433 -0
- package/src/channel.runtime.ts +454 -0
- package/src/channel.ts +130 -0
- package/src/config.ts +100 -0
- package/src/inbound.ts +151 -0
- package/src/live-snapshot.ts +210 -0
- package/src/outbound.ts +326 -0
- package/src/setup-surface.ts +281 -0
- package/src/signal-forwarder.ts +153 -0
|
@@ -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
|
+
};
|