@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,281 @@
1
+ // Setup wizard + adapter. The wizard runs when the user invokes `openclaw
2
+ // channels add` with no flags and picks `mutiro` from the selection list
3
+ // (passing `--channel mutiro` falls through to the non-interactive adapter
4
+ // path, not the wizard). The wizard detects the `mutiro` CLI,
5
+ // collects the agent directory path, validates that it looks like a real
6
+ // Mutiro agent workspace, and checks that auth is configured before writing
7
+ // `channels.mutiro.accounts.<id>.agentDir` into the OpenClaw config.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+
12
+ import {
13
+ createStandardChannelSetupStatus,
14
+ DEFAULT_ACCOUNT_ID,
15
+ formatDocsLink,
16
+ normalizeAccountId,
17
+ setSetupChannelEnabled,
18
+ type ChannelSetupAdapter,
19
+ type ChannelSetupWizard,
20
+ type OpenClawConfig,
21
+ } from "openclaw/plugin-sdk/setup";
22
+ import { runPluginCommandWithTimeout } from "openclaw/plugin-sdk/run-command";
23
+ import { detectBinary } from "openclaw/plugin-sdk/setup-tools";
24
+
25
+ import { listMutiroAccountIds, resolveMutiroAccount } from "./config.js";
26
+
27
+ const channel = "mutiro" as const;
28
+ const INSTALL_URL = "https://mutiro.com/downloads/install.sh";
29
+ const CREATE_AGENT_GUIDE = "https://www.mutiro.com/docs/guides/create-agent.md";
30
+
31
+ const MUTIRO_INTRO_LINES = [
32
+ "Point OpenClaw at an existing Mutiro agent directory.",
33
+ "Mutiro stays the messaging surface; OpenClaw becomes the brain.",
34
+ "",
35
+ "Before continuing:",
36
+ ` 1. Create a Mutiro agent: ${CREATE_AGENT_GUIDE}`,
37
+ " 2. Stop the built-in Mutiro brain for that agent — do not run two brains.",
38
+ "",
39
+ `Docs: ${formatDocsLink("/channels/mutiro", "channels/mutiro")}`,
40
+ ];
41
+
42
+ type MutiroAccountSection = {
43
+ agentDir?: string;
44
+ clientName?: string;
45
+ enabled?: boolean;
46
+ name?: string;
47
+ };
48
+
49
+ type MutiroSection = {
50
+ accounts?: Record<string, MutiroAccountSection>;
51
+ agentDir?: string;
52
+ clientName?: string;
53
+ enabled?: boolean;
54
+ };
55
+
56
+ function getSection(cfg: OpenClawConfig): MutiroSection {
57
+ const channels = (cfg as { channels?: Record<string, unknown> }).channels;
58
+ return (channels?.mutiro as MutiroSection | undefined) ?? {};
59
+ }
60
+
61
+ function isConfigured(cfg: OpenClawConfig, accountId: string): boolean {
62
+ const dir = resolveMutiroAccount(cfg, accountId).config.agentDir;
63
+ if (!dir) return false;
64
+ try {
65
+ return fs.statSync(dir).isDirectory();
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function patchAccount(params: {
72
+ cfg: OpenClawConfig;
73
+ accountId: string;
74
+ patch: Record<string, unknown>;
75
+ enabled?: boolean;
76
+ }): OpenClawConfig {
77
+ const section = getSection(params.cfg);
78
+
79
+ // Single-account shorthand: keep `agentDir` at the top-level section as long
80
+ // as no named accounts exist. This matches the plugin's config shape.
81
+ if (params.accountId === DEFAULT_ACCOUNT_ID && !section.accounts) {
82
+ return {
83
+ ...params.cfg,
84
+ channels: {
85
+ ...params.cfg.channels,
86
+ [channel]: {
87
+ ...section,
88
+ ...(params.enabled ? { enabled: true } : {}),
89
+ ...params.patch,
90
+ },
91
+ },
92
+ } as OpenClawConfig;
93
+ }
94
+
95
+ const accounts: Record<string, MutiroAccountSection> = { ...(section.accounts ?? {}) };
96
+ accounts[params.accountId] = {
97
+ ...(accounts[params.accountId] ?? {}),
98
+ ...params.patch,
99
+ };
100
+
101
+ return {
102
+ ...params.cfg,
103
+ channels: {
104
+ ...params.cfg.channels,
105
+ [channel]: {
106
+ ...section,
107
+ ...(params.enabled ? { enabled: true } : {}),
108
+ accounts,
109
+ },
110
+ },
111
+ } as OpenClawConfig;
112
+ }
113
+
114
+ function validateAgentDir(raw: string): string | undefined {
115
+ const trimmed = raw.trim();
116
+ if (!trimmed) return "Agent directory is required.";
117
+
118
+ const resolved = path.resolve(trimmed);
119
+ try {
120
+ const stat = fs.statSync(resolved);
121
+ if (!stat.isDirectory()) return "Path is not a directory.";
122
+ } catch {
123
+ return "Directory does not exist. Create the Mutiro agent first.";
124
+ }
125
+
126
+ const manifest = path.join(resolved, ".mutiro-agent.yaml");
127
+ if (!fs.existsSync(manifest)) {
128
+ return ".mutiro-agent.yaml not found. Is this the correct agent directory?";
129
+ }
130
+
131
+ return undefined;
132
+ }
133
+
134
+ export const mutiroSetupAdapter: ChannelSetupAdapter = {
135
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID,
136
+ validateInput: ({ input }) => {
137
+ const raw = input.authDir?.trim();
138
+ if (!raw) {
139
+ return "Mutiro requires --auth-dir (path to the Mutiro agent directory).";
140
+ }
141
+ return validateAgentDir(raw) ?? null;
142
+ },
143
+ applyAccountConfig: ({ cfg, accountId, input }) =>
144
+ patchAccount({
145
+ cfg,
146
+ accountId,
147
+ enabled: true,
148
+ patch: { agentDir: path.resolve((input.authDir ?? "").trim()) },
149
+ }),
150
+ };
151
+
152
+ export const mutiroSetupWizard: ChannelSetupWizard = {
153
+ channel,
154
+ status: createStandardChannelSetupStatus({
155
+ channelLabel: "Mutiro",
156
+ configuredLabel: "configured",
157
+ unconfiguredLabel: "needs agent directory",
158
+ configuredHint: "agent directory set",
159
+ unconfiguredHint: "point at a Mutiro agent dir",
160
+ configuredScore: 1,
161
+ unconfiguredScore: 0,
162
+ includeStatusLine: true,
163
+ resolveConfigured: ({ cfg, accountId }) =>
164
+ accountId
165
+ ? isConfigured(cfg, accountId)
166
+ : listMutiroAccountIds(cfg).some((id) => isConfigured(cfg, id)),
167
+ resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listMutiroAccountIds(cfg).length || 0}`],
168
+ }),
169
+ introNote: {
170
+ title: "Mutiro chatbridge setup",
171
+ lines: MUTIRO_INTRO_LINES,
172
+ },
173
+ prepare: async ({ prompter }) => {
174
+ const cliDetected = await detectBinary("mutiro");
175
+ if (cliDetected) return undefined;
176
+
177
+ await prompter.note(
178
+ [
179
+ "mutiro CLI not found on PATH.",
180
+ "",
181
+ "Install it:",
182
+ ` curl -sSL ${INSTALL_URL} | bash`,
183
+ "",
184
+ "Then log in and create (or pick) an agent:",
185
+ " mutiro auth login <email>",
186
+ ' mutiro agents create <username> "<Display Name>" --engine genie --bio "<bio>"',
187
+ "",
188
+ `Guide: ${CREATE_AGENT_GUIDE}`,
189
+ ].join("\n"),
190
+ "Mutiro CLI required",
191
+ );
192
+ return undefined;
193
+ },
194
+ credentials: [],
195
+ textInputs: [
196
+ {
197
+ inputKey: "authDir",
198
+ message: "Path to your Mutiro agent directory",
199
+ placeholder: "/Users/you/agents/my-agent",
200
+ required: true,
201
+ helpTitle: "Mutiro agent directory",
202
+ helpLines: [
203
+ "The folder that contains `.mutiro-agent.yaml` for the agent OpenClaw",
204
+ "should drive. Each configured account points to one agent directory.",
205
+ "",
206
+ "If you don't have one yet:",
207
+ ' mutiro agents create <username> "<Display Name>" --engine genie --bio "<bio>"',
208
+ `Guide: ${CREATE_AGENT_GUIDE}`,
209
+ ],
210
+ currentValue: ({ cfg, accountId }) =>
211
+ resolveMutiroAccount(cfg, accountId).config.agentDir,
212
+ keepPrompt: (value) => `Agent directory set (${value}). Keep it?`,
213
+ validate: ({ value }) => validateAgentDir(value),
214
+ normalizeValue: ({ value }) => path.resolve(value.trim()),
215
+ applySet: async ({ cfg, accountId, value }) =>
216
+ patchAccount({
217
+ cfg,
218
+ accountId,
219
+ enabled: true,
220
+ patch: { agentDir: path.resolve(value.trim()) },
221
+ }),
222
+ },
223
+ ],
224
+ finalize: async ({ cfg, accountId, prompter }) => {
225
+ const dir = resolveMutiroAccount(cfg, accountId).config.agentDir;
226
+ if (!dir) return undefined;
227
+
228
+ const whoami = await runPluginCommandWithTimeout({
229
+ argv: ["mutiro", "auth", "whoami"],
230
+ timeoutMs: 5_000,
231
+ cwd: dir,
232
+ });
233
+
234
+ if (whoami.code !== 0) {
235
+ await prompter.note(
236
+ [
237
+ "Could not confirm `mutiro auth whoami`.",
238
+ "",
239
+ "Finish Mutiro-side setup before starting the gateway:",
240
+ ` cd ${dir}`,
241
+ " mutiro auth login <email>",
242
+ "",
243
+ "Also make sure the built-in Mutiro brain is NOT running for this agent —",
244
+ "running two brains at once will fight over the same conversations:",
245
+ " mutiro agent doctor",
246
+ ].join("\n"),
247
+ "Mutiro agent readiness",
248
+ );
249
+ }
250
+
251
+ return undefined;
252
+ },
253
+ completionNote: {
254
+ title: "Mutiro bridge configured",
255
+ lines: [
256
+ "Next steps:",
257
+ " 1. Stop the built-in Mutiro brain for this agent (don't run two brains).",
258
+ " 2. Allow Mutiro tools on the OpenClaw agent by adding `mutiro*` to",
259
+ " `tools.alsoAllow` (or enable individually: mutiro_send_voice_message,",
260
+ " mutiro_send_card, mutiro_forward_message).",
261
+ " 3. Start the gateway: openclaw gateway run",
262
+ "",
263
+ "Once the gateway is running, talk to your agent:",
264
+ " Web: https://app.mutiro.com",
265
+ " CLI: mutiro chat",
266
+ " Mobile: Mutiro app (iOS / Android)",
267
+ " Desktop: Mutiro desktop app (macOS / Windows / Linux)",
268
+ "",
269
+ "Sharing the agent with other users:",
270
+ " Mutiro has its own server-side allowlist — denied users are blocked",
271
+ " before their messages ever reach OpenClaw. Manage it with:",
272
+ " mutiro agents allowlist get <agent-username>",
273
+ " mutiro agents allow <agent-username> <username>",
274
+ " Full guide (paste into your AI assistant):",
275
+ " https://github.com/mutirolabs/openclaw-brain/blob/main/docs/guides/manage-allowlist.md",
276
+ "",
277
+ `Docs: ${formatDocsLink("/channels/mutiro", "channels/mutiro")}`,
278
+ ],
279
+ },
280
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
281
+ };
@@ -0,0 +1,153 @@
1
+ // Bridges OpenClaw's mid-turn reply-dispatch hooks into Mutiro's
2
+ // signal.emit envelopes so the user sees a live "typing / searching /
3
+ // remembering" indicator while the agent works. Fire-and-forget: signals
4
+ // are transient UI chrome, not messages. No correlation required.
5
+ //
6
+ // Maps OpenClaw callbacks (from GetReplyOptions) to Mutiro's signal
7
+ // vocabulary (SIGNAL_TYPE_* in spec/protobuf/shared/signal.proto).
8
+
9
+ import type { BridgeSession } from "./bridge-session.js";
10
+ import type { MutiroOutboundTarget } from "./outbound.js";
11
+
12
+ const REASONING_DEBOUNCE_MS = 500;
13
+ const TOOL_DETAIL_MAX_CHARS = 120;
14
+
15
+ // Tool-name → Mutiro signal + human-readable "intent" label. The signal
16
+ // type drives the UI pill style; the intent is the detail_text prefix
17
+ // the user actually reads. Tools without a dedicated SIGNAL_TYPE still
18
+ // get a readable intent ("Reading file: src/x.ts") via CUSTOM so the
19
+ // user sees what's happening rather than the raw tool name.
20
+ type ToolSignalSpec = { signal: string; intent: string };
21
+ const TOOL_SIGNAL_MAP: Record<string, ToolSignalSpec> = {
22
+ // Web
23
+ web_search: { signal: "SIGNAL_TYPE_WEB_SEARCHING", intent: "Searching web" },
24
+ web_fetch: { signal: "SIGNAL_TYPE_WEB_FETCHING", intent: "Fetching" },
25
+ fetch: { signal: "SIGNAL_TYPE_WEB_FETCHING", intent: "Fetching" },
26
+
27
+ // Memory / recall
28
+ recall: { signal: "SIGNAL_TYPE_RECALLING", intent: "Recalling" },
29
+ memory_search: { signal: "SIGNAL_TYPE_RECALLING", intent: "Searching memory" },
30
+ memory: { signal: "SIGNAL_TYPE_READING_MEMORY", intent: "Reading memory" },
31
+ memory_remember: { signal: "SIGNAL_TYPE_WRITING_MEMORY", intent: "Saving memory" },
32
+ memory_write: { signal: "SIGNAL_TYPE_WRITING_MEMORY", intent: "Saving memory" },
33
+
34
+ // Media
35
+ image_generate: { signal: "SIGNAL_TYPE_CREATING_IMAGE", intent: "Creating image" },
36
+ image: { signal: "SIGNAL_TYPE_CREATING_IMAGE", intent: "Working with image" },
37
+
38
+ // Scheduling / planning
39
+ cron: { signal: "SIGNAL_TYPE_SCHEDULING", intent: "Scheduling" },
40
+ update_plan: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Updating plan" },
41
+
42
+ // Mutiro-native channel tools
43
+ mutiro_send_voice_message: { signal: "SIGNAL_TYPE_SENDING_VOICE", intent: "Sending voice" },
44
+ mutiro_send_card: { signal: "SIGNAL_TYPE_ATTACHING_FILE", intent: "Preparing card" },
45
+ mutiro_forward_message: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Forwarding message" },
46
+
47
+ // File operations (coding profile). Mutiro has no dedicated SIGNAL_TYPE_*
48
+ // for file I/O; use CUSTOM + readable intent so the user still sees the
49
+ // action. Phase (e.g. the path being touched) is appended when present.
50
+ read: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Reading file" },
51
+ write: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Writing file" },
52
+ edit: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Editing file" },
53
+ apply_patch: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Applying patch" },
54
+
55
+ // Shell / process
56
+ exec: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Running command" },
57
+ bash: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Running command" },
58
+ process: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Managing process" },
59
+
60
+ // UI surfaces
61
+ canvas: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Updating canvas" },
62
+ browser: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Browsing" },
63
+
64
+ // Sessions / control plane
65
+ sessions_list: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Listing sessions" },
66
+ sessions_history: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Reading history" },
67
+ sessions_send: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Sending to session" },
68
+ session_status: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Checking status" },
69
+ message: { signal: "SIGNAL_TYPE_CUSTOM", intent: "Messaging" },
70
+ };
71
+
72
+ const truncateDetail = (raw: string): string => {
73
+ const collapsed = raw.replace(/\s+/g, " ").trim();
74
+ if (collapsed.length <= TOOL_DETAIL_MAX_CHARS) return collapsed;
75
+ return `${collapsed.slice(0, TOOL_DETAIL_MAX_CHARS - 1).trimEnd()}…`;
76
+ };
77
+
78
+ export type SignalForwarder = {
79
+ thinking: () => void;
80
+ typing: () => void;
81
+ reasoning: () => void;
82
+ toolStart: (name?: string, phase?: string) => void;
83
+ itemStart: (params: { name?: string; title?: string; phase?: string }) => void;
84
+ compactionStart: () => void;
85
+ compactionEnd: () => void;
86
+ planUpdate: (title?: string) => void;
87
+ custom: (detail: string) => void;
88
+ turnComplete: () => void;
89
+ };
90
+
91
+ export const createSignalForwarder = (
92
+ session: BridgeSession,
93
+ target: MutiroOutboundTarget,
94
+ ): SignalForwarder => {
95
+ let lastReasoningAt = 0;
96
+ const emit = (signalType: string, detail?: string) => {
97
+ session.outbound.emitSignal(target, signalType, detail ?? "");
98
+ };
99
+
100
+ return {
101
+ thinking: () => emit("SIGNAL_TYPE_THINKING", "Processing…"),
102
+ typing: () => emit("SIGNAL_TYPE_TYPING", "Writing response…"),
103
+ reasoning: () => {
104
+ // onReasoningStream fires per-token; throttle so we ship at most one
105
+ // REASONING pulse every REASONING_DEBOUNCE_MS to avoid spamming the
106
+ // host + Mutiro clients.
107
+ const now = Date.now();
108
+ if (now - lastReasoningAt < REASONING_DEBOUNCE_MS) return;
109
+ lastReasoningAt = now;
110
+ emit("SIGNAL_TYPE_REASONING", "Thinking…");
111
+ },
112
+ toolStart: (name, _phase) => {
113
+ const trimmedName = (name ?? "").trim();
114
+ if (!trimmedName) {
115
+ emit("SIGNAL_TYPE_TOOL_RUNNING");
116
+ return;
117
+ }
118
+ // `onToolStart.phase` is a lifecycle marker ("start"/"update"/"end"),
119
+ // not semantic payload — ignore it. Tools with args (read, write,
120
+ // exec, etc.) fire a follow-up `onItemEvent` whose `title` carries
121
+ // the real detail (e.g. "read src/x.ts"); itemEvent() refines the
122
+ // pill to that value. For tools that never emit an item event, the
123
+ // intent label alone is still meaningful.
124
+ const spec = TOOL_SIGNAL_MAP[trimmedName];
125
+ const intent = spec?.intent ?? trimmedName;
126
+ emit(spec?.signal ?? "SIGNAL_TYPE_CUSTOM", truncateDetail(intent));
127
+ },
128
+ itemStart: (params) => {
129
+ // Higher-fidelity source than toolStart: title resolves tool args
130
+ // into a display ("read src/x.ts", "exec pytest -k foo", etc.) via
131
+ // OpenClaw's inferToolMetaFromArgs. When we have both the tool
132
+ // signal type AND the rich title, emit with the specific signal
133
+ // type so the UI still shows the right pill style.
134
+ const name = (params.name ?? "").trim();
135
+ const title = (params.title ?? "").trim();
136
+ if (!name && !title) return;
137
+ const spec = name ? TOOL_SIGNAL_MAP[name] : undefined;
138
+ const detail = truncateDetail(title || spec?.intent || name);
139
+ emit(spec?.signal ?? "SIGNAL_TYPE_CUSTOM", detail);
140
+ },
141
+ compactionStart: () => emit("SIGNAL_TYPE_REMEMBERING", "Organizing context…"),
142
+ compactionEnd: () => {
143
+ // No explicit clear — the next signal (or TURN_COMPLETE) replaces the
144
+ // visible pill on Mutiro clients.
145
+ },
146
+ planUpdate: (title) => {
147
+ const detail = (title ?? "").trim();
148
+ emit("SIGNAL_TYPE_CUSTOM", detail ? `Planning: ${truncateDetail(detail)}` : "Planning…");
149
+ },
150
+ custom: (detail) => emit("SIGNAL_TYPE_CUSTOM", truncateDetail(detail)),
151
+ turnComplete: () => emit("SIGNAL_TYPE_TURN_COMPLETE"),
152
+ };
153
+ };