@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,454 @@
1
+ // Heavy runtime surface for the Mutiro channel plugin. Owns the registry of
2
+ // active BridgeSession instances keyed by account and serves inbound/outbound
3
+ // calls from the plugin's gateway and outbound adapters.
4
+ //
5
+ // Keeping this file separate from `channel.ts` means the light plugin entry
6
+ // does not pull the NDJSON + child_process machinery into gateway startup.
7
+
8
+ import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/core";
9
+ import type { ChannelGatewayContext } from "openclaw/plugin-sdk/channel-contract";
10
+ import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
11
+ import { recordInboundSessionAndDispatchReply } from "openclaw/plugin-sdk/inbound-reply-dispatch";
12
+ import {
13
+ dispatchReplyWithBufferedBlockDispatcher,
14
+ finalizeInboundContext,
15
+ } from "openclaw/plugin-sdk/reply-dispatch-runtime";
16
+ import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
17
+ import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
18
+
19
+ type ChannelOutboundContext = Parameters<NonNullable<ChannelOutboundAdapter["sendText"]>>[0];
20
+ type OutboundDeliveryResult = Awaited<
21
+ ReturnType<NonNullable<ChannelOutboundAdapter["sendText"]>>
22
+ >;
23
+
24
+ import { startBridgeSession, type BridgeSession } from "./bridge-session.js";
25
+ import type { ResolvedMutiroAccount } from "./config.js";
26
+ import type { InboundDeliver, InboundMessage } from "./inbound.js";
27
+ import { normalizeOutputText } from "./bridge-messages.js";
28
+
29
+ type StartContext = ChannelGatewayContext<ResolvedMutiroAccount>;
30
+
31
+ const sessions = new Map<string, BridgeSession>();
32
+
33
+ const sessionKey = (channel: string, accountId: string) => `${channel}:${accountId}`;
34
+
35
+ const requireSessionForAccount = (accountId: string | null | undefined): BridgeSession => {
36
+ const session = sessions.get(sessionKey("mutiro", accountId ?? "default"));
37
+ if (!session) {
38
+ throw new Error(
39
+ `mutiro channel: no active bridge session for account "${accountId ?? "default"}". gateway.startAccount must run first.`,
40
+ );
41
+ }
42
+ return session;
43
+ };
44
+
45
+ // Public accessor used by agent tools that need to reach the active bridge
46
+ // session without throwing when the channel is not running yet.
47
+ export const getMutiroBridgeSession = (
48
+ accountId: string | null | undefined,
49
+ ): BridgeSession | undefined =>
50
+ sessions.get(sessionKey("mutiro", accountId ?? "default"));
51
+
52
+ /**
53
+ * Runs the agent against a delegated task prompt and returns the
54
+ * accumulated reply text. Used by `task.request`, which — unlike
55
+ * `message.observed` — expects the full reply text inside the
56
+ * `ChatBridgeTaskResult` envelope instead of on the outbound bridge.
57
+ *
58
+ * We reuse the same reply-dispatch path as buildDeliverBridge but swap
59
+ * the deliver callback: instead of shipping chunks via bridge.message.send
60
+ * we accumulate them into a buffer the caller returns to the host.
61
+ *
62
+ * Tool side-effects (mutiro_send_voice_message, mutiro_send_card, etc.)
63
+ * still fire normally through their own execute() paths — only the
64
+ * agent's plain reply text is captured for the task result.
65
+ */
66
+ const buildResolveTaskRequest = (ctx: StartContext) =>
67
+ async (params: {
68
+ conversationId: string;
69
+ accountId: string;
70
+ username?: string;
71
+ prompt: string;
72
+ promptData?: Record<string, string>;
73
+ metadata?: Record<string, string>;
74
+ timeoutMs?: number;
75
+ requestId?: string;
76
+ }): Promise<string> => {
77
+ const senderUsername = (params.username ?? "").trim() || "system";
78
+ const route = resolveAgentRoute({
79
+ cfg: ctx.cfg,
80
+ channel: "mutiro",
81
+ accountId: params.accountId,
82
+ peer: { kind: "direct", id: senderUsername },
83
+ });
84
+ const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId });
85
+ const messageSid = params.requestId ?? `task-${Date.now()}`;
86
+ const ctxPayload = finalizeInboundContext({
87
+ Body: params.prompt,
88
+ BodyForAgent: params.prompt,
89
+ RawBody: params.prompt,
90
+ CommandBody: params.prompt,
91
+ From: senderUsername,
92
+ To: params.conversationId,
93
+ SessionKey: route.sessionKey,
94
+ AccountId: route.accountId ?? params.accountId,
95
+ ChatType: "direct",
96
+ ConversationLabel: params.conversationId,
97
+ SenderId: senderUsername,
98
+ Provider: "mutiro",
99
+ Surface: "mutiro",
100
+ MessageSid: messageSid,
101
+ MessageSidFull: messageSid,
102
+ Timestamp: Date.now(),
103
+ OriginatingChannel: "mutiro",
104
+ OriginatingTo: params.conversationId,
105
+ });
106
+
107
+ const accumulator: string[] = [];
108
+ const dispatchPromise = recordInboundSessionAndDispatchReply({
109
+ cfg: ctx.cfg,
110
+ channel: "mutiro",
111
+ accountId: params.accountId,
112
+ agentId: route.agentId,
113
+ routeSessionKey: route.sessionKey,
114
+ storePath,
115
+ ctxPayload,
116
+ recordInboundSession,
117
+ dispatchReplyWithBufferedBlockDispatcher,
118
+ deliver: async (payload) => {
119
+ const chunk = String(payload.text ?? "");
120
+ if (chunk) accumulator.push(chunk);
121
+ },
122
+ onRecordError: (err) =>
123
+ ctx.log?.warn?.(`mutiro: task record error: ${formatError(err)}`),
124
+ onDispatchError: (err, info) =>
125
+ ctx.log?.warn?.(`mutiro: task dispatch error (${info.kind}): ${formatError(err)}`),
126
+ });
127
+
128
+ // Honor timeout_ms: whichever finishes first, use what accumulated.
129
+ // If the dispatch ran past the deadline we return partial output and
130
+ // let the background promise drain; the host already has its answer.
131
+ if (params.timeoutMs && params.timeoutMs > 0) {
132
+ await Promise.race([
133
+ dispatchPromise,
134
+ new Promise<void>((resolve) => setTimeout(resolve, params.timeoutMs)),
135
+ ]);
136
+ } else {
137
+ await dispatchPromise;
138
+ }
139
+
140
+ return normalizeOutputText(accumulator.join(""));
141
+ };
142
+
143
+ const buildDeliverBridge = (ctx: StartContext): InboundDeliver =>
144
+ async (inbound: InboundMessage) => {
145
+ const session = requireSessionForAccount(inbound.accountId);
146
+
147
+ // Resolve the routing / session / ctxPayload pieces directly from the
148
+ // public plugin-sdk helpers rather than relying on `ctx.channelRuntime`
149
+ // to carry the full runtime surface (it does not for bundled channels).
150
+ const route = resolveAgentRoute({
151
+ cfg: ctx.cfg,
152
+ channel: "mutiro",
153
+ accountId: inbound.accountId,
154
+ peer: { kind: "direct", id: inbound.senderUsername },
155
+ });
156
+ const storePath = resolveStorePath(ctx.cfg.session?.store, { agentId: route.agentId });
157
+
158
+ const target = {
159
+ conversationId: inbound.conversationId,
160
+ replyToMessageId: inbound.messageId,
161
+ };
162
+ const { createSignalForwarder } = await import("./signal-forwarder.js");
163
+ const signals = createSignalForwarder(session, target);
164
+ // Fire a THINKING pulse immediately so the user sees feedback while
165
+ // dispatch warms up (model selection, memory loads, etc.). Subsequent
166
+ // on* callbacks replace it with more specific signals.
167
+ signals.thinking();
168
+
169
+ const mediaPaths = inbound.mediaPaths ?? [];
170
+ const mediaTypes = inbound.mediaTypes ?? [];
171
+ const ctxPayload = finalizeInboundContext({
172
+ Body: inbound.text,
173
+ BodyForAgent: inbound.text,
174
+ RawBody: inbound.text,
175
+ CommandBody: inbound.text,
176
+ From: inbound.senderUsername,
177
+ To: inbound.conversationId,
178
+ SessionKey: route.sessionKey,
179
+ AccountId: route.accountId ?? inbound.accountId,
180
+ ChatType: "direct",
181
+ ConversationLabel: inbound.conversationId,
182
+ SenderId: inbound.senderUsername,
183
+ Provider: "mutiro",
184
+ Surface: "mutiro",
185
+ MessageSid: inbound.messageId,
186
+ MessageSidFull: inbound.messageId,
187
+ Timestamp: Date.now(),
188
+ OriginatingChannel: "mutiro",
189
+ OriginatingTo: inbound.conversationId,
190
+ ...(mediaPaths.length > 0
191
+ ? {
192
+ MediaPath: mediaPaths[0],
193
+ MediaPaths: mediaPaths,
194
+ MediaType: mediaTypes[0],
195
+ MediaTypes: mediaTypes,
196
+ }
197
+ : {}),
198
+ });
199
+
200
+ await recordInboundSessionAndDispatchReply({
201
+ cfg: ctx.cfg,
202
+ channel: "mutiro",
203
+ accountId: inbound.accountId,
204
+ agentId: route.agentId,
205
+ routeSessionKey: route.sessionKey,
206
+ storePath,
207
+ ctxPayload,
208
+ recordInboundSession,
209
+ dispatchReplyWithBufferedBlockDispatcher,
210
+ deliver: async (payload) => {
211
+ const text = normalizeOutputText(String(payload.text ?? ""));
212
+ if (!text) return;
213
+ await session.outbound.sendText(target, text);
214
+ },
215
+ // replyOptions taps OpenClaw's mid-turn hooks to forward progress
216
+ // into Mutiro's signal stream. Each on* callback maps to a specific
217
+ // SIGNAL_TYPE_* so the user sees "searching web", "remembering",
218
+ // "writing response" pills instead of a single static "thinking".
219
+ replyOptions: {
220
+ onAssistantMessageStart: () => signals.typing(),
221
+ onReasoningStream: () => signals.reasoning(),
222
+ onToolStart: (payload) => signals.toolStart(payload.name, payload.phase),
223
+ // onItemEvent carries richer detail than onToolStart — `title` is
224
+ // resolved from tool args (e.g. "read src/x.ts"). Refine only on
225
+ // the start phase; "end"/"update" would thrash the pill.
226
+ onItemEvent: (payload) => {
227
+ if (payload.phase && payload.phase !== "start") return;
228
+ signals.itemStart({
229
+ name: payload.name,
230
+ title: payload.title,
231
+ phase: payload.phase,
232
+ });
233
+ },
234
+ onCompactionStart: () => signals.compactionStart(),
235
+ onCompactionEnd: () => signals.compactionEnd(),
236
+ onPlanUpdate: (payload) => signals.planUpdate(payload.title),
237
+ },
238
+ onRecordError: (err) => ctx.log?.warn?.(`mutiro: record session error: ${formatError(err)}`),
239
+ onDispatchError: (err, info) =>
240
+ ctx.log?.warn?.(`mutiro: dispatch error (${info.kind}): ${formatError(err)}`),
241
+ });
242
+
243
+ // Close the signal stream and the host-owned turn lifecycle.
244
+ // TURN_COMPLETE clears any lingering pill in the Mutiro UI; endTurn
245
+ // releases the host-side pending turn regardless of whether visible
246
+ // replies were emitted.
247
+ signals.turnComplete();
248
+ session.outbound.endTurn(target);
249
+ };
250
+
251
+ export const startMutiroAccount = async (ctx: StartContext) => {
252
+ const key = sessionKey("mutiro", ctx.accountId);
253
+ const existing = sessions.get(key);
254
+ if (existing) {
255
+ return;
256
+ }
257
+
258
+ const { account } = ctx;
259
+ if (!account.configured || !account.config.agentDir) {
260
+ ctx.log?.warn?.(`mutiro: account "${ctx.accountId}" is not configured (missing agentDir)`);
261
+ return;
262
+ }
263
+
264
+ // The gateway expects startAccount to stay pending for the lifetime of the
265
+ // channel. Resolving early is interpreted as "channel exited" and triggers
266
+ // an auto-restart loop. We block on exit-or-abort via this deferred.
267
+ let settleLifecycle: () => void = () => {};
268
+ const lifecycle = new Promise<void>((resolve) => {
269
+ settleLifecycle = resolve;
270
+ });
271
+
272
+ const session = await startBridgeSession({
273
+ accountId: ctx.accountId,
274
+ agentDir: account.config.agentDir,
275
+ clientName: account.config.clientName,
276
+ requestedOptionalCapabilities: account.config.requestedOptionalCapabilities,
277
+ deliver: buildDeliverBridge(ctx),
278
+ resolveTaskRequest: buildResolveTaskRequest(ctx),
279
+ resolveLiveSnapshot: async (params) => {
280
+ // Lazy-load the snapshot module so startup stays clean and the
281
+ // plugin-sdk/config-runtime surface is only touched when the host
282
+ // actually requests a live handoff (i.e. a voice call starts).
283
+ const { buildLiveSnapshot } = await import("./live-snapshot.js");
284
+ return buildLiveSnapshot({
285
+ cfg: ctx.cfg,
286
+ accountId: params.accountId,
287
+ conversationId: params.conversationId,
288
+ callerUsername: params.username,
289
+ callId: params.callId,
290
+ agentUsername: session?.getAgentUsername() ?? "",
291
+ });
292
+ },
293
+ logger: ctx.log
294
+ ? {
295
+ info: ctx.log.info,
296
+ warn: ctx.log.warn,
297
+ error: ctx.log.error,
298
+ }
299
+ : undefined,
300
+ onHostExit: (code) => {
301
+ sessions.delete(key);
302
+ ctx.log?.info?.(`mutiro: host (${ctx.accountId}) exited with code ${code}`);
303
+ ctx.setStatus({
304
+ ...ctx.getStatus(),
305
+ running: false,
306
+ connected: false,
307
+ lastDisconnect: { at: Date.now(), status: code ?? undefined },
308
+ });
309
+ settleLifecycle();
310
+ },
311
+ });
312
+
313
+ sessions.set(key, session);
314
+ ctx.setStatus({
315
+ ...ctx.getStatus(),
316
+ running: true,
317
+ connected: true,
318
+ lastConnectedAt: Date.now(),
319
+ lastStartAt: Date.now(),
320
+ });
321
+
322
+ const onAbort = () => {
323
+ void stopSession(key).finally(settleLifecycle);
324
+ };
325
+ if (ctx.abortSignal.aborted) {
326
+ onAbort();
327
+ } else {
328
+ ctx.abortSignal.addEventListener("abort", onAbort, { once: true });
329
+ }
330
+
331
+ await lifecycle;
332
+ };
333
+
334
+ export const stopMutiroAccount = async (ctx: StartContext) => {
335
+ await stopSession(sessionKey("mutiro", ctx.accountId));
336
+ ctx.setStatus({
337
+ ...ctx.getStatus(),
338
+ running: false,
339
+ connected: false,
340
+ lastStopAt: Date.now(),
341
+ });
342
+ };
343
+
344
+ const stopSession = async (key: string) => {
345
+ const session = sessions.get(key);
346
+ if (!session) return;
347
+ sessions.delete(key);
348
+ await session.shutdown();
349
+ };
350
+
351
+ export const sendMutiroText = async (
352
+ ctx: ChannelOutboundContext,
353
+ ): Promise<OutboundDeliveryResult> => {
354
+ const session = requireSessionForAccount(ctx.accountId);
355
+ await session.outbound.sendText(
356
+ {
357
+ conversationId: ctx.to,
358
+ replyToMessageId: ctx.replyToId ?? ctx.threadId?.toString() ?? "",
359
+ },
360
+ ctx.text,
361
+ );
362
+ return {
363
+ channel: "mutiro",
364
+ messageId: ctx.replyToId ?? `mutiro-${Date.now()}`,
365
+ conversationId: ctx.to,
366
+ };
367
+ };
368
+
369
+ export const sendMutiroMedia = async (
370
+ ctx: ChannelOutboundContext,
371
+ ): Promise<OutboundDeliveryResult> => {
372
+ if (!ctx.mediaUrl) {
373
+ throw new Error("sendMedia called without mediaUrl");
374
+ }
375
+ const session = requireSessionForAccount(ctx.accountId);
376
+ await session.outbound.sendFile(
377
+ {
378
+ conversationId: ctx.to,
379
+ replyToMessageId: ctx.replyToId ?? ctx.threadId?.toString() ?? "",
380
+ },
381
+ {
382
+ filePath: ctx.mediaUrl,
383
+ caption: ctx.text,
384
+ },
385
+ );
386
+ return {
387
+ channel: "mutiro",
388
+ messageId: ctx.replyToId ?? `mutiro-${Date.now()}`,
389
+ conversationId: ctx.to,
390
+ };
391
+ };
392
+
393
+ const formatError = (err: unknown) =>
394
+ err instanceof Error ? err.message : JSON.stringify(err);
395
+
396
+ // Dispatcher for ChannelMessageActionAdapter.handleAction. Declared here so
397
+ // the heavy runtime does the bridge work, and the light `actions.ts` file
398
+ // stays a pure control-plane adapter that loads this lazily.
399
+ export const handleMutiroMessageAction = async (params: {
400
+ action: string;
401
+ params: Record<string, unknown>;
402
+ accountId?: string;
403
+ readStringArg: (params: Record<string, unknown>, ...keys: string[]) => string | undefined;
404
+ }) => {
405
+ const session = requireSessionForAccount(params.accountId);
406
+
407
+ if (params.action === "react") {
408
+ const messageId = params.readStringArg(params.params, "messageId", "message_id", "to");
409
+ const emoji = params.readStringArg(params.params, "emoji", "reaction");
410
+ if (!messageId) {
411
+ return {
412
+ content: [{ type: "text" as const, text: "react requires a messageId." }],
413
+ details: { ok: false, reason: "missing_message_id" },
414
+ };
415
+ }
416
+ if (!emoji) {
417
+ return {
418
+ content: [{ type: "text" as const, text: "react requires an emoji." }],
419
+ details: { ok: false, reason: "missing_emoji" },
420
+ };
421
+ }
422
+ try {
423
+ const raw = await session.outbound.react({ messageId, emoji });
424
+ return {
425
+ content: [{ type: "text" as const, text: `Reacted ${emoji} to ${messageId}.` }],
426
+ details: { ok: true, raw },
427
+ };
428
+ } catch (err) {
429
+ const message = formatError(err);
430
+ return {
431
+ content: [{ type: "text" as const, text: `Failed to react: ${message}` }],
432
+ details: { ok: false, reason: "bridge_error", error: message },
433
+ };
434
+ }
435
+ }
436
+
437
+ return {
438
+ content: [
439
+ {
440
+ type: "text" as const,
441
+ text: `Unsupported Mutiro message action: ${params.action}`,
442
+ },
443
+ ],
444
+ details: { ok: false, reason: "unsupported_action", action: params.action },
445
+ };
446
+ };
447
+
448
+ // Barrel export consumed by the plugin entry via `loadBundledEntryExportSync`.
449
+ export const mutiroChannelRuntime = {
450
+ startMutiroAccount,
451
+ stopMutiroAccount,
452
+ sendMutiroText,
453
+ sendMutiroMedia,
454
+ };
package/src/channel.ts ADDED
@@ -0,0 +1,130 @@
1
+ // OpenClaw channel plugin definition. This file is the "hot" import path
2
+ // loaded during gateway startup and plugin discovery, so it stays narrow:
3
+ // manifest metadata plus a lazy handle into the heavier runtime module.
4
+ //
5
+ // The runtime (`channel.runtime.ts`) owns subprocess lifecycle, envelope
6
+ // dispatch, and the per-account bridge session registry.
7
+
8
+ import type {
9
+ ChannelOutboundAdapter,
10
+ ChannelPlugin,
11
+ } from "openclaw/plugin-sdk/core";
12
+ import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
13
+ import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
14
+
15
+ import { mutiroMessageActions } from "./actions.js";
16
+ import { mutiroAgentTools } from "./agent-tools.js";
17
+ import { mutiroConfigAdapter, type ResolvedMutiroAccount } from "./config.js";
18
+ import { mutiroSetupAdapter, mutiroSetupWizard } from "./setup-surface.js";
19
+
20
+ const loadMutiroChannelRuntime = createLazyRuntimeNamedExport(
21
+ () => import("./channel.runtime.js"),
22
+ "mutiroChannelRuntime",
23
+ );
24
+
25
+ const outbound: ChannelOutboundAdapter = {
26
+ deliveryMode: "direct",
27
+
28
+ // `sendText` is called whenever OpenClaw wants to push a text reply into a
29
+ // Mutiro conversation. The `accountId` selects which active bridge session
30
+ // to route through; `to` is the Mutiro `conversation_id`; `replyToId` is the
31
+ // message the reply threads under.
32
+ async sendText(ctx) {
33
+ const runtime = await loadMutiroChannelRuntime();
34
+ return runtime.sendMutiroText(ctx);
35
+ },
36
+
37
+ // `sendMedia` is the single-shot media path. The channel runtime uses the
38
+ // bridge-local `media.upload` command to stage the file, then attaches it
39
+ // to a `message.send`.
40
+ async sendMedia(ctx) {
41
+ const runtime = await loadMutiroChannelRuntime();
42
+ return runtime.sendMutiroMedia(ctx);
43
+ },
44
+ };
45
+
46
+ export const mutiroPlugin: ChannelPlugin<ResolvedMutiroAccount> = createChatChannelPlugin<
47
+ ResolvedMutiroAccount
48
+ >({
49
+ base: {
50
+ id: "mutiro",
51
+ meta: {
52
+ id: "mutiro",
53
+ label: "Mutiro",
54
+ selectionLabel: "Mutiro (plugin)",
55
+ docsPath: "/channels/mutiro",
56
+ docsLabel: "mutiro",
57
+ blurb: "chatbridge channel; configure a Mutiro agent directory to enable.",
58
+ order: 80,
59
+ quickstartAllowFrom: true,
60
+ markdownCapable: true,
61
+ },
62
+ capabilities: {
63
+ chatTypes: ["direct", "group"],
64
+ reactions: true,
65
+ reply: true,
66
+ media: true,
67
+ },
68
+ config: mutiroConfigAdapter,
69
+ agentTools: mutiroAgentTools,
70
+ actions: mutiroMessageActions,
71
+
72
+ // Setup surfaces: `setup` is the non-interactive adapter path
73
+ // (`openclaw channels add --channel mutiro [flags]`); `setupWizard` is what
74
+ // runs when the user invokes `openclaw channels add` with no flags and
75
+ // picks `mutiro` from the selection list.
76
+ setup: mutiroSetupAdapter,
77
+ setupWizard: mutiroSetupWizard,
78
+
79
+ // Messaging adapter: teaches OpenClaw how to recognize a Mutiro target.
80
+ // Without it, reactions/forwards/cross-channel sends fail with
81
+ // "Unknown target" because the core resolver can't match a
82
+ // `conv_<uuid>` conversation id or a leading-@ username against any
83
+ // directory/id pattern it knows about.
84
+ messaging: {
85
+ normalizeTarget: (raw: string) => {
86
+ const trimmed = raw.trim();
87
+ if (!trimmed) return undefined;
88
+ // Strip a leading @ on usernames so downstream comparisons and the
89
+ // bridge's `to_username` field see the raw handle.
90
+ return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
91
+ },
92
+ targetResolver: {
93
+ hint: "Use a Mutiro conversation id (e.g. conv_<uuid>) or @username.",
94
+ looksLikeId: (raw: string, normalized?: string) => {
95
+ const value = (normalized ?? raw).trim();
96
+ if (!value) return false;
97
+ // conv_<...> = conversation id; bare @handle or a plain alphanumeric
98
+ // username both route to message.send_voice / react / etc.
99
+ return /^conv_/i.test(value) || /^@/.test(raw) || /^[A-Za-z0-9_.-]+$/.test(value);
100
+ },
101
+ resolveTarget: async ({ input, normalized }) => {
102
+ const value = (normalized || input).trim().replace(/^@/, "");
103
+ if (!value) return null;
104
+ const isConversation = /^conv_/i.test(value);
105
+ return {
106
+ to: value,
107
+ kind: isConversation ? "group" : "user",
108
+ display: isConversation ? value : `@${value}`,
109
+ source: "normalized",
110
+ };
111
+ },
112
+ },
113
+ },
114
+
115
+ // Gateway lifecycle: startAccount spawns the bridge subprocess for this
116
+ // account and wires inbound observed messages into OpenClaw's reply
117
+ // dispatcher. stopAccount tears the subprocess down.
118
+ gateway: {
119
+ async startAccount(ctx) {
120
+ const runtime = await loadMutiroChannelRuntime();
121
+ return runtime.startMutiroAccount(ctx);
122
+ },
123
+ async stopAccount(ctx) {
124
+ const runtime = await loadMutiroChannelRuntime();
125
+ await runtime.stopMutiroAccount(ctx);
126
+ },
127
+ },
128
+ },
129
+ outbound,
130
+ });
package/src/config.ts ADDED
@@ -0,0 +1,100 @@
1
+ // Mutiro channel configuration and per-account resolution.
2
+ //
3
+ // The channel supports one or more named accounts; each account pins a
4
+ // specific Mutiro agent directory that `mutiro agent host --mode=bridge`
5
+ // should run from. We reuse OpenClaw's `createScopedChannelConfigAdapter` so
6
+ // the account lifecycle (list/default/resolve) flows through the same shape
7
+ // the Plugin SDK already knows how to drive.
8
+
9
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
10
+ import type { ChannelPlugin, OpenClawConfig } from "openclaw/plugin-sdk/core";
11
+
12
+ export { DEFAULT_ACCOUNT_ID };
13
+
14
+ export type MutiroAccountConfig = {
15
+ agentDir: string;
16
+ clientName?: string;
17
+ requestedOptionalCapabilities?: string[];
18
+ enabled?: boolean;
19
+ name?: string;
20
+ };
21
+
22
+ export type ResolvedMutiroAccount = {
23
+ accountId: string;
24
+ enabled: boolean;
25
+ configured: boolean;
26
+ name?: string;
27
+ config: MutiroAccountConfig;
28
+ };
29
+
30
+ type MutiroChannelSection = {
31
+ accounts?: Record<string, MutiroAccountConfig & { name?: string; enabled?: boolean }>;
32
+ // Support single-account shorthand by keeping top-level fields too.
33
+ agentDir?: string;
34
+ clientName?: string;
35
+ requestedOptionalCapabilities?: string[];
36
+ enabled?: boolean;
37
+ };
38
+
39
+ const readMutiroSection = (cfg: OpenClawConfig): MutiroChannelSection | undefined => {
40
+ const channels = (cfg as { channels?: Record<string, unknown> }).channels;
41
+ return channels?.mutiro as MutiroChannelSection | undefined;
42
+ };
43
+
44
+ const resolveAccountConfig = (
45
+ cfg: OpenClawConfig,
46
+ accountId: string,
47
+ ): MutiroAccountConfig | undefined => {
48
+ const section = readMutiroSection(cfg);
49
+ if (!section) return undefined;
50
+
51
+ if (accountId === DEFAULT_ACCOUNT_ID && section.agentDir) {
52
+ return {
53
+ agentDir: section.agentDir,
54
+ clientName: section.clientName,
55
+ requestedOptionalCapabilities: section.requestedOptionalCapabilities,
56
+ enabled: section.enabled,
57
+ };
58
+ }
59
+
60
+ return section.accounts?.[accountId];
61
+ };
62
+
63
+ export const listMutiroAccountIds = (cfg: OpenClawConfig): string[] => {
64
+ const section = readMutiroSection(cfg);
65
+ const named = Object.keys(section?.accounts ?? {});
66
+ if (named.length > 0) return named;
67
+ return section?.agentDir ? [DEFAULT_ACCOUNT_ID] : [];
68
+ };
69
+
70
+ export const resolveDefaultMutiroAccountId = (cfg: OpenClawConfig): string => {
71
+ const ids = listMutiroAccountIds(cfg);
72
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
73
+ };
74
+
75
+ export const resolveMutiroAccount = (
76
+ cfg: OpenClawConfig,
77
+ accountId?: string | null,
78
+ ): ResolvedMutiroAccount => {
79
+ const id = accountId || resolveDefaultMutiroAccountId(cfg);
80
+ const base = resolveAccountConfig(cfg, id) ?? { agentDir: "" };
81
+ const configured = Boolean(base.agentDir);
82
+ return {
83
+ accountId: id,
84
+ enabled: base.enabled !== false,
85
+ configured,
86
+ name: base.name,
87
+ config: base,
88
+ };
89
+ };
90
+
91
+ // Build the ChannelConfigAdapter directly. The helpers in
92
+ // `channel-config-helpers` expect allowlist and clear-base-field accessors
93
+ // that Mutiro does not use, so we wire the two required hooks manually.
94
+ export const mutiroConfigAdapter: ChannelPlugin<ResolvedMutiroAccount>["config"] = {
95
+ listAccountIds: listMutiroAccountIds,
96
+ resolveAccount: resolveMutiroAccount,
97
+ defaultAccountId: resolveDefaultMutiroAccountId,
98
+ isEnabled: (account: ResolvedMutiroAccount) => account.enabled,
99
+ isConfigured: (account: ResolvedMutiroAccount) => account.configured,
100
+ };